Journal

Doctrine ORM : dompter le Unit of Work et les pièges de performance

Journal du jeudi 20 août 2026, généré le 23 août à 18:55

Le Unit of Work, cœur du fonctionnement de Doctrine

Doctrine ne synchronise jamais la base à chaque appel de méthode. Il accumule les changements dans un Unit of Work et ne les traduit en requêtes SQL qu'au moment du flush(). Comprendre ce mécanisme évite deux erreurs classiques : croire qu'un persist() écrit immédiatement en base, et multiplier les flush() par souci de "sécurité", ce qui casse les performances et le regroupement des requêtes en une seule transaction.

Les quatre états d'une entité

  • New : l'objet vient d'être créé, Doctrine ne le connaît pas.
  • Managed : l'entité est suivie par l'Identity Map et sera synchronisée au flush.
  • Detached : l'entité existe en base mais n'est plus liée à l'EntityManager courant.
  • Removed : marquée pour suppression au prochain flush.

L'Identity Map garantit qu'une même ligne de base ne produit qu'un seul objet PHP par requête à l'EntityManager, ce qui évite les incohérences si deux relations pointent vers la même entité.

Le piège classique : le problème N+1

Le lazy loading est pratique mais dangereux dans une boucle :

foreach ($commandes as $commande) {
    echo $commande->getClient()->getNom(); // 1 requête SQL par itération
}

La solution consiste à précharger la relation avec un fetch join en DQL :

$qb = $entityManager->createQueryBuilder();
$qb->select('c', 'cl')
   ->from(Commande::class, 'c')
   ->join('c.client', 'cl');
$commandes = $qb->getQuery()->getResult();

Une seule requête récupère alors commandes et clients associés.

Traitement de gros volumes

Pour importer ou modifier des milliers d'entités, ne gardez pas tout en mémoire : Doctrine conserve chaque entité managée dans l'Identity Map, ce qui fait grossir la consommation RAM et ralentit le flush. Utilisez un flush par lot et libérez régulièrement :

foreach ($lignes as $i => $ligne) {
    $entityManager->persist(new Produit($ligne));
    if (($i % 200) === 0) {
        $entityManager->flush();
        $entityManager->clear(); // détache toutes les entités
    }
}
$entityManager->flush();

Autres points à surveiller

  • DQL vs QueryBuilder : le DQL est plus lisible pour des requêtes fixes, le QueryBuilder est préférable dès que la requête se construit dynamiquement (filtres optionnels).
  • Cascade : cascade={"persist"} propage la persistance aux entités liées, mais n'active pas la suppression automatique sans orphanRemoval ou cascade={"remove"} explicite.
  • Cache de second niveau : utile pour des données peu volatiles (référentiels), mais source de bugs subtils si mal invalidé après une écriture directe en SQL.
  • Migrations : générez-les avec doctrine:migrations:diff puis relisez-les toujours à la main, l'outil peut proposer des suppressions destructrices sur des colonnes renommées.