Depuis PHP 5.1, PDO tient sa promesse d'abstraction : un objet unique pour parler à MySQL, PostgreSQL ou SQLite. Mais cette uniformité a toujours eu un prix — impossible d'appeler getWarnings() après un INSERT MySQL sans passer par un SHOW WARNINGS manuel, impossible d'utiliser le COPY natif de Postgres sans recourir à pg_query(), impossible de déclarer une fonction d'agrégation SQLite custom sans bidouille. PHP 8.4, sorti en GA le 21 novembre 2024, comble ces lacunes avec une approche chirurgicale : des sous-classes Pdo\Mysql, Pdo\Pgsql et Pdo\Sqlite qui exposent les méthodes propres à chaque moteur tout en restant 100 % rétrocompatibles. Voici ce que ça change concrètement dans une application Symfony avec Doctrine DBAL — et pourquoi ça rebat les cartes sur la stratégie de tests en mémoire.
PHP 8.4 : une sous-classe par driver, transparente à l'instanciation
Le design retenu par l'équipe PHP est élégant dans sa sobriété : tu ne changes rien à ta façon de créer une connexion. Quand PHP 8.4 exécute new PDO('mysql:host=127.0.0.1;dbname=app', 'user', 'pass'), il ne retourne plus un PDO générique — il retourne un objet de type Pdo\Mysql. La sous-classe est sélectionnée automatiquement selon le préfixe du DSN. L'instanceof fonctionne, le type-hint fonctionne, l'analyse statique suit. Aucun changement de syntaxe n'est requis pour bénéficier de la promotion.
Pdo\Mysql—getWarningCount() : intetgetWarnings() : ?arraypour accéder aux warnings MySQL après une requête, sansSHOW WARNINGSPdo\Pgsql—escapeIdentifier(),escapeLiteral(),copyFromArray(),copyToArray(),copyFromFile(),copyToFile(),getNotify(),getPid()pour les opérations PostgreSQL avancéesPdo\Sqlite—createFunction(),createAggregate(),createCollation(),openBlob()désormais déclarées formellement (elles existaient comme méthodes non typées viaPDO::sqliteCreateFunction()avant PHP 8.4)Pdo\Dblib,Pdo\Firebird,Pdo\Oci— les autres drivers embarquent également leurs sous-classes respectives
La nuance importante : tu peux aussi instancier directement new \Pdo\Mysql('mysql:...', ...) si tu veux un signal d'intention explicite dans le code. Dans les deux cas — new PDO() ou new \Pdo\Mysql() — le runtime PHP 8.4 produit la même instance. C'est surtout la première forme qui t'intéresse avec Doctrine DBAL, puisque c'est DBAL qui crée la connexion à ta place.
Doctrine DBAL et la connexion native : ce que PHP 8.4 change sans rien casser
Doctrine DBAL ne gère pas lui-même le socket réseau vers MySQL. Pour le driver PDO_MySQL, il appelle new PDO(...) en interne. Avec PHP 8.4, cet appel retourne maintenant un Pdo\Mysql — et DBAL n'a eu à modifier absolument rien pour ça. La mise à niveau PHP suffit. DBAL expose cette connexion native via getNativeConnection() sur son objet Doctrine\DBAL\Connection.
<?php
use Doctrine\DBAL\Connection;
// $connection est injecté via le container Symfony
$native = $connection->getNativeConnection();
// PHP 8.4 + driver MySQL : transparence totale
var_dump($native instanceof \Pdo\Mysql); // bool(true)
var_dump($native instanceof \PDO); // bool(true) — la hiérarchie est préservéeLe problème, c'est que la signature déclarée de getNativeConnection() dans DBAL 4.x retourne object. PHPStan et Psalm savent que tu as un object, pas un Pdo\Mysql. L'appel à getWarningCount() déclenchera une erreur d'analyse statique de niveau 8 ou 9 sur une méthode inconnue. D'où le besoin d'un wrapper minimaliste.
Un wrapper typé : 20 lignes, zéro dépendance
L'objectif est de centraliser la récupération et la vérification du type, pour que tous les services qui consomment des fonctionnalités MySQL-specific travaillent avec un type déclaré Pdo\Mysql. Un assert() ou une vérification instanceof suffit — pas de bibliothèque, pas de patch DBAL. L'exception à l'exécution protège aussi contre les environnements mal configurés où le driver serait différent de celui attendu.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Database;
use Doctrine\DBAL\Connection;
final class MysqlNativeConnection
{
public function __construct(
private readonly Connection $connection,
) {}
public function get(): \Pdo\Mysql
{
$native = $this->connection->getNativeConnection();
if (!$native instanceof \Pdo\Mysql) {
throw new \RuntimeException(
sprintf(
'Connexion native Pdo\\Mysql attendue, obtenu : %s',
get_debug_type($native),
)
);
}
return $native;
}
}# config/services.yaml
services:
App\Infrastructure\Database\MysqlNativeConnection:
arguments:
$connection: '@doctrine.dbal.default_connection'PHPStan voit le type de retour déclaré comme Pdo\Mysql — l'analyse statique niveau 9 est satisfaite sur tous les appels en aval. Ce service s'injecte proprement dans n'importe quel repository ou handler qui a besoin de fonctionnalités MySQL-specific. Si ton application tourne sur PostgreSQL, tu crées un PgsqlNativeConnection calqué sur le même patron, en remplaçant Pdo\Mysql par Pdo\Pgsql.
Méthodes MySQL-only : getWarnings() sans SHOW WARNINGS
Le cas d'usage le plus immédiat pour les projets MySQL est la gestion des warnings silencieux. En mode STRICT_TRANS_TABLES désactivé — fréquent sur des bases héritées — MySQL peut tronquer une valeur ou ignorer une contrainte souple sans lever d'exception PDO. Avant PHP 8.4, la seule façon de détecter ça proprement était de lancer SHOW WARNINGS après chaque requête sensible : oublis, overhead réseau, parsing manuel du résultat. Avec getWarningCount() et getWarnings(), c'est natif, typé, et synchrone avec la dernière requête exécutée.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Repository;
use App\Infrastructure\Database\MysqlNativeConnection;
final class BulkProductImport
{
public function __construct(
private readonly MysqlNativeConnection $mysql,
) {}
/**
* @param list<array{sku: string, name: string, price: float}> $rows
*/
public function import(array $rows): void
{
$pdo = $this->mysql->get();
$stmt = $pdo->prepare(
'INSERT INTO products (sku, name, price) VALUES (?, ?, ?)'
. ' ON DUPLICATE KEY UPDATE name = VALUES(name), price = VALUES(price)'
);
foreach ($rows as $row) {
$stmt->execute([$row['sku'], $row['name'], $row['price']]);
// PHP 8.4 : aucun SHOW WARNINGS, méthode native sur Pdo\Mysql
if ($pdo->getWarningCount() > 0) {
$warnings = $pdo->getWarnings();
foreach ($warnings ?? [] as $warning) {
// Chaque $warning expose ->level, ->code, ->message
if ($warning->level === 'Warning') {
throw new \RuntimeException(
"MySQL warning [{$warning->code}] : {$warning->message}"
);
}
}
}
}
}
}Besoin d'un expert Symfony ?
Réserver un appel →Le fait que getWarnings() retourne null quand il n'y a aucun warning — et pas un tableau vide — est un détail d'implémentation à ne pas oublier, d'où le ?? [] dans le foreach. PHPStan te le rappellera si tu passes au niveau 8+.
PostgreSQL : COPY et escape d'identifiants en natif
Les gains côté PostgreSQL sont différents mais tout aussi concrets. Pdo\Pgsql::escapeIdentifier() règle un vecteur d'injection souvent sous-estimé : les noms de colonnes ou de tables construits dynamiquement. Les paramètres PDO (? et :name) ne couvrent que les valeurs — pas les identifiants. Avant PHP 8.4, la seule alternative propre était pg_escape_identifier(), qui nécessite une ressource de connexion pg_connect() distincte de PDO. La commande COPY via copyFromArray() est l'autre apport majeur : pour les imports massifs vers Postgres, COPY est 5 à 20 fois plus rapide qu'une série d'INSERT.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Database;
use Doctrine\DBAL\Connection;
final class PgsqlNativeConnection
{
public function __construct(
private readonly Connection $connection,
) {}
public function get(): \Pdo\Pgsql
{
$native = $this->connection->getNativeConnection();
if (!$native instanceof \Pdo\Pgsql) {
throw new \RuntimeException(
sprintf('Pdo\\Pgsql attendu, obtenu : %s', get_debug_type($native))
);
}
return $native;
}
}<?php
// Escape sécurisé d'un identifiant dynamique — sans pg_escape_identifier()
$pdo = $this->pgsql->get();
$safeColumn = $pdo->escapeIdentifier($userProvidedColumnName);
$sql = "SELECT {$safeColumn} FROM products WHERE active = TRUE";
// Import massif via COPY — 10x plus rapide que des INSERT en boucle
$csvRows = array_map(
fn (array $r): array => [$r['sku'], $r['name'], (string) $r['price']],
$rows,
);
$pdo->copyFromArray(
$csvRows,
'products', // table cible
"\t", // séparateur de colonnes
'\\N', // représentation de NULL
'sku, name, price', // colonnes cibles
);L'impact sur la stratégie de tests SQLite en mémoire
C'est ici que les sous-classes PDO créent une friction réelle avec les habitudes établies dans l'écosystème Symfony. De nombreux projets configurent un second Doctrine DBAL avec driver: pdo_sqlite et url: sqlite:///:memory: pour les tests d'intégration rapides : pas de processus MySQL à lancer, isolation parfaite entre tests, vitesse d'exécution maximale. Avec PHP 8.3 et un PDO générique, le code passait parce qu'il n'avait aucun type déclaré. Avec PHP 8.4 et un MysqlNativeConnection injecté dans les services testés, tout appel à get() dans un contexte SQLite lève une RuntimeException immédiate — et c'est la bonne réponse : la divergence est maintenant explicite plutôt que silencieuse.
- Garder SQLite in-memory pour les tests de schéma et migrations uniquement, en excluant les services qui dépendent de fonctionnalités driver-specific
- Utiliser MySQL réel dans les tests d'intégration via Docker Compose et tmpfs pour les performances — c'est la solution la plus fiable si tu consommes des méthodes MySQL-only
- Extraire une interface pour les services qui dépendent de
MysqlNativeConnectionet les mocker dans les tests unitaires — on teste la logique métier, pas le driver - Ne jamais patcher le wrapper pour laisser passer SQLite en production : ça masque la divergence et produit des bugs silencieux
# docker-compose.test.yml
services:
mysql_test:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: app_test
ports:
- "3307:3306"
tmpfs:
- /var/lib/mysql # Données en RAM : performances proches d'SQLite in-memory<!-- phpunit.xml.dist -->
<php>
<env name="DATABASE_URL"
value="mysql://root:test@127.0.0.1:3307/app_test?serverVersion=8.4&charset=utf8mb4"/>
</php>La combinaison MySQL 8.4 en tmpfs + PHPStan niveau 9 sur les wrappers typés produit une suite de tests d'intégration qui teste réellement ce qui tourne en production. La légère augmentation du temps de setup — démarrage du conteneur MySQL — se rentabilise en fiabilité sur les cas limites que SQLite ne peut pas reproduire : ON DUPLICATE KEY UPDATE, collations spécifiques, warnings de truncation, plans d'exécution réels.
PHPStan niveau 9 et cohabitation PHP 8.3/8.4
PHPStan 1.x reconnaît les classes Pdo\Mysql, Pdo\Pgsql et Pdo\Sqlite comme sous-types de PDO dans ses stubs PHP 8.4. Un niveau 9 sur un codebase qui utilise les wrappers décrits ci-dessus passe sans ignored-error supplémentaire. Si tu maintiens la compatibilité PHP 8.3 dans ton composer.json ("php": ">=8.3"), le typage de getNativeConnection() ne changera pas entre les deux versions — mais l'instanceof au runtime échouera sur PHP 8.3 pour Pdo\Mysql puisque la classe n'existe tout simplement pas encore. Le guard le plus propre dans ce cas :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Database;
use Doctrine\DBAL\Connection;
final class MysqlNativeConnection
{
public function __construct(
private readonly Connection $connection,
) {}
public function get(): \Pdo\Mysql
{
$native = $this->connection->getNativeConnection();
// class_exists() retourne false sur PHP 8.3 : erreur explicite plutôt qu'un Fatal Error cryptique
if (!class_exists(\Pdo\Mysql::class)) {
throw new \RuntimeException(
'PHP 8.4+ requis pour les connexions Pdo\\Mysql typées.'
);
}
if (!$native instanceof \Pdo\Mysql) {
throw new \RuntimeException(
sprintf(
'Connexion native Pdo\\Mysql attendue, obtenu : %s',
get_debug_type($native),
)
);
}
return $native;
}
}class_exists(\Pdo\Mysql::class) retourne false sur PHP 8.3, ce qui produit un message d'erreur actionnable plutôt qu'un PHP Fatal Error cryptique sur l'instanceof. Côté PHPStan, le type de retour \Pdo\Mysql est valide sur PHP 8.4 ; sur 8.3, l'analyseur émet une erreur sur la classe inconnue — signal que le code exige PHP 8.4. Si tu gardes la compatibilité 8.3, marque le minimum requis dans composer.json et laisse le CI valider que le wrapper n'est jamais appelé sur 8.3. Les sous-classes PDO de PHP 8.4 ne révolutionnent pas l'architecture d'une application Symfony — mais elles scellent le seul vrai point faible de l'abstraction PDO depuis 2005 : l'accès aux fonctionnalités driver-specific sans sacrifier le typage statique.
Besoin d'un expert Symfony ?
20 ans d'expérience sur l'écosystème PHP/Symfony.