Connessione al database in PHP: PDO, DSN e query preparate

Chi sviluppa per il web ha quasi sempre bisogno di un database per salvare e rileggere i dati. Con PHP la connessione si stabilisce in poche righe, a patto di scegliere l'estensione giusta e le opzioni giuste. Vediamo come si fa con PDO nel 2026.

Connessione al database in PHP: PDO, DSN e query preparate
Risposta rapida

Usa PDO con charset=utf8mb4 nel DSN, ATTR_ERRMODE su exception e ATTR_EMULATE_PREPARES a false. Da PHP 8.0 gli errori PDO lanciano già un'eccezione per impostazione predefinita, e da PHP 8.1 lo fa anche mysqli: il pattern if (!$conn) die(...) non scatta più. Ogni valore che arriva dall'utente passa da una query preparata.

Connettersi a un database è la prima cosa che fa quasi ogni applicazione PHP. La scelta dell’estensione e il modo in cui scrivi la connessione decidono due cose: quanto saranno leggibili gli errori il giorno in cui qualcosa si rompe, e quanto il codice resiste alle SQL injection. Qui vediamo la connessione come la si scrive nel 2026, su PHP 8.5, con le trappole che sono cambiate a partire da PHP 8.0.

PDO, la connessione da ricordare

PDO è l’interfaccia generica di PHP per i database. Parla con MySQL e MariaDB, ma anche con PostgreSQL, SQLite o SQL Server, a parità di codice applicativo. È la scelta predefinita, salvo motivi precisi per fare altrimenti.

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,
]);

In questo blocco contano tre dettagli, e sono esattamente quelli che si dimenticano.

  • charset=utf8mb4 nel DSN. Senza, la connessione usa il set di caratteri predefinito del server. Le emoji e una parte dei caratteri asiatici diventano punti interrogativi, e su certi set di caratteri la codifica si trasforma in un vettore di injection. Il charset si dichiara nel DSN, non con una query SET NAMES.
  • ATTR_EMULATE_PREPARES a false. Per impostazione predefinita il driver MySQL simula le query preparate lato PHP invece di inviarle al server. Disattivando l’emulazione è il server a separare la query dai dati, e i tipi interi tornano indietro come interi.
  • ATTR_ERRMODE esplicito. Da PHP 8.0 ERRMODE_EXCEPTION è già il valore predefinito. Lasciarlo scritto non cambia il comportamento, ma dice a chi legge il codice che gli errori risalgono come eccezioni.

Verifica sulla macchina di test:

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

La password non si scrive nel file

Le credenziali non hanno niente da fare in un file versionato. La forma più semplice è leggerle dall’ambiente, con un messaggio chiaro se la variabile manca.

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,
        ],
    );
}

L’operatore ?: seguito da throw funziona da PHP 8.0, da quando throw è diventato un’espressione. La connessione è memorizzata in una variabile statica: aprire una connessione per richiesta è più che sufficiente.

Il messaggio di errore non deve mai trapelare

Un’eccezione di connessione contiene l’host, l’utente e a volte la password nella traccia. Mostrata così com’è su una pagina pubblica, regala metà del lavoro a chi attacca.

php
try {
    $pdo = connexion();
} catch (PDOException $e) {
    // Il dettaglio va nel log, mai nella risposta HTTP
    error_log('Connexion base impossible : ' . $e->getMessage());
    http_response_code(503);
    exit('Service momentanément indisponible.');
}

Da PHP 8.2 i parametri contrassegnati con #[SensitiveParameter] compaiono come Object(SensitiveParameterValue) nelle tracce. È una protezione utile, ma copre solo la traccia: il messaggio, invece, resta chiacchierone.

Connettersi è solo metà del lavoro

Una connessione senza query preparate non protegge da niente. La regola sta in una frase: i dati non si concatenano mai dentro la query.

php
// Da non fare: il valore entra direttamente nella query
$sql = "SELECT * FROM membres WHERE email = '" . $_GET['email'] . "'";

// Query preparata: il valore viaggia a parte
$requete = $pdo->prepare('SELECT id, email, cree_le FROM membres WHERE email = ?');
$requete->execute([$_GET['email'] ?? '']);
$membre = $requete->fetch();

I segnaposto con nome si leggono meglio non appena i valori sono più di due:

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); // tipizza sempre il LIMIT
$requete->execute();

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

Il PDO::PARAM_INT sul LIMIT non è decorativo, e il suo effetto dipende da un’impostazione che si tende a dimenticare. Ecco cosa succede con bindValue(':limite', 3) senza tipo, a seconda che l’emulazione delle query preparate sia attiva o no:

Server EMULATE_PREPARES Senza PARAM_INT Con PARAM_INT
MariaDB 11.8.9 false accettato accettato
MariaDB 11.8.9 true (predefinito) errore SQL 1064 accettato
MySQL 8.4.11 false accettato accettato
MySQL 8.4.11 true (predefinito) errore SQL 1064 accettato

Con l’emulazione è PDO a costruire la query, e mette il valore fra apici: LIMIT '3', che il server rifiuta. È la configurazione predefinita, quindi il caso in cui finisce la maggior parte delle persone. Tipizzare il valore funziona in tutte e quattro le situazioni: è l’unica forma da ricordare.

Un segnaposto non sostituisce mai il nome di una tabella o di una colonna. Se il nome della colonna arriva dall’utente, va validato contro un elenco chiuso:

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";

E mysqli?

mysqli resta valido se il progetto non parlerà mai con altro che MySQL o MariaDB. Il suo comportamento è cambiato su un punto importante: da PHP 8.1 mysqli_report() è impostato su MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT, e gli errori lanciano un’eccezione invece di restituire 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();

Il test di connessione che non testa più niente

Il pattern che segue si trascina in moltissimi tutorial, comprese le versioni precedenti di questo. Non funziona più da PHP 8.1.

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

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

Eseguito su PHP 8.5.10 con una password sbagliata, questo codice non produce il messaggio previsto. Produce questo:

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

La riga if (!$conn) non viene mai raggiunta: mysqli_connect() ha già lanciato l’eccezione. Il die() è codice morto, e l’errore grezzo parte verso il browser se la visualizzazione degli errori è attiva. La riscrittura con try / catch vista sopra è oggi l’unica forma corretta.

Le funzioni mysql_* non esistono più

I mysql_connect(), mysql_query() e mysql_real_escape_string() che si incontrano ancora in vecchi script sono stati rimossi da PHP 7.0, uscito nel 2015. Su PHP 8.5:

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

Non c’è nessuna compatibilità da garantire, solo una migrazione da fare, preferibilmente verso PDO.

Per approfondire

Per inserire un grosso volume di righe la connessione è solo metà del discorso: vedi leggere file di grandi dimensioni in PHP, che mostra l’inserimento a lotti dentro una transazione. Per reindirizzare al termine di un’elaborazione, vedi creare un redirect in PHP. Una volta messa in piedi la connessione, il passo successivo è non riscriverla in ogni file. Gli altri tutorial PHP dell’hub Sviluppo web coprono i mattoni vicini: installare Composer per organizzare il progetto, e le basi della programmazione a oggetti in PHP per sistemare questa connessione in una classe invece che in una funzione globale.

Errori frequenti

Il die() che non viene mai eseguito Da PHP 8.1 mysqli_connect() lancia una mysqli_sql_exception prima di arrivare a if (!$conn). Il messaggio di errore grezzo parte verso il browser al posto del messaggio previsto.
Charset assente dal DSN Senza charset=utf8mb4 le emoji e certi caratteri diventano punti interrogativi, e la codifica può fare da vettore di injection.
LIMIT senza PDO::PARAM_INT Con l'emulazione delle query preparate, attiva per impostazione predefinita, PDO scrive LIMIT '3' e il server restituisce un errore SQL 1064. Verificato su MariaDB 11.8.9 e MySQL 8.4.11.
Messaggio dell'eccezione mostrato a video La traccia di una PDOException contiene l'host e l'utente. Va nel log, mai nella risposta HTTP.
Segnaposto al posto di un nome di colonna Un segnaposto sostituisce solo un valore. Il nome di una tabella o di una colonna si valida contro un elenco chiuso.

MySQLPDOPHPSécurité

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.