PHP database connection: PDO, prepared statements, mysqli

As a web developer, you constantly need to talk to a database to store and read data. In PHP, opening that connection takes only a few lines. This article goes through them, from the PDO connection to the prepared statements that go with it.

PHP database connection: PDO, prepared statements, mysqli
Quick answer

Use PDO with charset=utf8mb4 in the DSN, ATTR_ERRMODE set to exception and ATTR_EMULATE_PREPARES set to false. Since PHP 8.0 PDO errors already throw an exception by default, and since PHP 8.1 so do mysqli errors: the if (!$conn) die(...) pattern never fires any more. Every value coming from the user goes through a prepared statement.

Connecting to a database is the first thing most PHP applications do. The extension you pick and the way you write that connection decide two things: how readable the errors are the day it breaks, and how well the code stands up to SQL injection. This article shows the connection as it is written in 2026, on PHP 8.5, with the pitfalls that have changed since PHP 8.0.

PDO, the connection worth learning

PDO is PHP’s generic database interface. It talks to MySQL and MariaDB, but also to PostgreSQL, SQLite or SQL Server, with the same application code. It is the default choice, unless you have a precise reason to do otherwise.

src/Base.php
<?php

declare(strict_types=1);

$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=ma_base;charset=utf8mb4';

$pdo = new PDO($dsn, 'utilisateur', 'mot_de_passe', [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
]);

Three details matter in that block, and they are exactly the ones people forget.

  • charset=utf8mb4 in the DSN. Without it, the connection uses the server’s default character set. Emoji and part of the Asian character range turn into question marks, and on some character sets the encoding itself becomes an injection vector. The charset belongs in the DSN, not in a SET NAMES query.
  • ATTR_EMULATE_PREPARES set to false. By default the MySQL driver emulates prepared statements on the PHP side instead of sending them to the server. Turn that off and the server is the one separating the query from the data, and integer types come back as integers.
  • An explicit ATTR_ERRMODE. Since PHP 8.0, ERRMODE_EXCEPTION is already the default. Writing it out changes nothing about the behaviour, but it tells whoever reads the code that errors come back as exceptions.

Checked on the test machine:

php
$pdo2 = new PDO($dsn, $utilisateur, $motDePasse); // no options
var_dump($pdo2->getAttribute(PDO::ATTR_ERRMODE) === PDO::ERRMODE_EXCEPTION);
// bool(true) on PHP 8.5.10

The password does not belong in the file

Credentials have no business in a versioned file. The simplest form reads them from the environment, with a clear message when a variable is missing.

src/connexion.php
<?php

declare(strict_types=1);

function connexion(): PDO
{
    static $pdo = null;
    if ($pdo instanceof PDO) {
        return $pdo;
    }

    $hote = getenv('DB_HOST') ?: '127.0.0.1';
    $base = getenv('DB_NAME') ?: throw new RuntimeException('DB_NAME manquant');
    $user = getenv('DB_USER') ?: throw new RuntimeException('DB_USER manquant');
    $pass = getenv('DB_PASS') ?: '';

    return $pdo = new PDO(
        "mysql:host={$hote};dbname={$base};charset=utf8mb4",
        $user,
        $pass,
        [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ],
    );
}

The ?: operator followed by throw works since PHP 8.0, where throw became an expression. The connection is kept in a static variable: one connection per request is more than enough.

Never let the error message leak

A connection exception carries the host, the user and sometimes the password in its trace. Printed as it is on a public page, it hands an attacker half the work.

php
try {
    $pdo = connexion();
} catch (PDOException $e) {
    // The detail goes to the log, never into the HTTP response
    error_log('Connexion base impossible : ' . $e->getMessage());
    http_response_code(503);
    exit('Service momentanément indisponible.');
}

Since PHP 8.2, parameters marked #[SensitiveParameter] show up as Object(SensitiveParameterValue) in traces. That is a useful protection, but it only covers the trace: the message itself stays talkative.

Connecting is only half the job

A connection without prepared statements protects nothing. The rule fits in one sentence: data is never concatenated into the query.

php
// What not to do: the value goes straight into the query
$sql = "SELECT * FROM membres WHERE email = '" . $_GET['email'] . "'";

// Prepared statement: the value travels separately
$requete = $pdo->prepare('SELECT id, email, cree_le FROM membres WHERE email = ?');
$requete->execute([$_GET['email'] ?? '']);
$membre = $requete->fetch();

Named placeholders read better as soon as there are more than two values:

php
$requete = $pdo->prepare(
    'SELECT id, titre FROM articles
     WHERE statut = :statut AND publie_le >= :depuis
     ORDER BY publie_le DESC LIMIT :limite'
);
$requete->bindValue(':statut', 'publie');
$requete->bindValue(':depuis', '2026-01-01');
$requete->bindValue(':limite', 10, PDO::PARAM_INT); // always type the LIMIT
$requete->execute();

foreach ($requete as $ligne) {
    echo $ligne['titre'], "\n";
}

The PDO::PARAM_INT on the LIMIT is not decorative, and its effect depends on a setting that is easy to forget. Here is what bindValue(':limite', 3) gives without a type, depending on whether prepared statement emulation is on:

Server EMULATE_PREPARES Without PARAM_INT With PARAM_INT
MariaDB 11.8.9 false accepted accepted
MariaDB 11.8.9 true (default) SQL error 1064 accepted
MySQL 8.4.11 false accepted accepted
MySQL 8.4.11 true (default) SQL error 1064 accepted

With emulation on, PDO builds the query itself and wraps the value in quotes: LIMIT '3', which the server refuses. That is the default configuration, so it is the case most people run into. Typing the value works in all four situations, and it is the only form worth remembering.

A placeholder never stands in for a table or a column name. If the column name comes from the user, validate it against a closed list:

php
$colonnesTriables = ['titre', 'publie_le', 'vues'];
$tri = in_array($_GET['tri'] ?? '', $colonnesTriables, true) ? $_GET['tri'] : 'publie_le';
$sql = "SELECT id, titre FROM articles ORDER BY {$tri} DESC";

What about mysqli?

mysqli is still a valid choice if the project will only ever talk to MySQL or MariaDB. Its behaviour changed on one major point: since PHP 8.1, mysqli_report() is set to MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, and errors throw an exception instead of returning false.

php
<?php

declare(strict_types=1);

try {
    $mysqli = new mysqli('127.0.0.1', $utilisateur, $motDePasse, 'ma_base');
    $mysqli->set_charset('utf8mb4');
} catch (mysqli_sql_exception $e) {
    error_log('Connexion mysqli impossible : ' . $e->getMessage());
    http_response_code(503);
    exit('Service momentanément indisponible.');
}

$requete = $mysqli->prepare('SELECT id, email FROM membres WHERE email = ?');
$requete->bind_param('s', $email);
$requete->execute();
$membre = $requete->get_result()->fetch_assoc();

The connection test that no longer tests anything

The following pattern is still lying around in a great many tutorials, earlier versions of this one included. It has not worked since PHP 8.1.

php
$conn = mysqli_connect($hote, $utilisateur, $motDePasse, $base);

if (!$conn) {
    die('La connexion a échoué : ' . mysqli_connect_error());
}

Run on PHP 8.5.10 with a wrong password, this code does not produce the message it promises. It produces this:

code
PHP Fatal error:  Uncaught mysqli_sql_exception: Access denied for user
'gekkode'@'172.27.0.10' (using password: YES) in /app/test.php:8

The if (!$conn) line is never reached: mysqli_connect() threw the exception first. The die() is dead code, and the raw error goes out to the browser if error display is on. The try / catch rewrite above is the only correct form today.

The mysql_* functions are gone

The mysql_connect(), mysql_query() and mysql_real_escape_string() calls you still come across in old scripts were removed in PHP 7.0, released in 2015. On PHP 8.5:

php
var_dump(function_exists('mysql_connect'));           // bool(false)
var_dump(function_exists('mysql_real_escape_string')); // bool(false)

There is no compatibility to keep, only a migration to carry out, to PDO preferably.

Going further

To insert a large volume of rows, the connection is only half the subject: see reading large files with PHP, which shows batch inserts inside a transaction. To redirect after processing, see creating a redirect in PHP. Once the connection is in place, the next step is not writing it again in every file. The other PHP tutorials on the web development hub cover the neighbouring building blocks: installing Composer to organise the project, and the basics of OOP in PHP to keep that connection in a class rather than in a global function.

Common errors

The die() that never runs Since PHP 8.1, mysqli_connect() throws a mysqli_sql_exception before if (!$conn) is ever reached. The raw error message goes out to the browser instead of the message you wrote.
Charset missing from the DSN Without charset=utf8mb4, emoji and some characters turn into question marks, and the encoding can serve as an injection vector.
LIMIT without PDO::PARAM_INT With prepared statement emulation, on by default, PDO writes LIMIT '3' and the server returns SQL error 1064. Checked on MariaDB 11.8.9 and MySQL 8.4.11.
Exception message displayed The trace of a PDOException contains the host and the user. It goes to the log, never into the HTTP response.
Placeholder used for a column name A placeholder only ever stands in for a value. A table or column name is validated against a closed list.

MySQLPDOPHPSécurité

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.