Journal

Doctrine ORM : Unit of Work, proxies et pièges N+1

Journal du mercredi 26 août 2026, généré le 26 août à 21:43

Le mapping par attributs PHP

Depuis Doctrine ORM 2.9+ et surtout en version 3.x, le mapping par attributs PHP 8 remplace les annotations Doctrine et le YAML/XML. Il n'y a plus besoin de parser des docblocks : les métadonnées sont natives au langage.

#[ORM\Entity]
#[ORM\Table(name: 'articles')]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private int $id;

    #[ORM\Column(length: 255)]
    private string $titre;

    #[ORM\ManyToOne(targetEntity: Auteur::class, inversedBy: 'articles')]
    private Auteur $auteur;
}

Unit of Work : ce qui se passe vraiment à flush()

Doctrine ne persiste rien immédiatement. L'EntityManager délègue à l'Unit of Work, qui garde un instantané (snapshot) de chaque entité chargée. À l'appel de flush(), Doctrine compare l'état actuel des objets à ce snapshot pour calculer un changeset, puis génère les INSERT/UPDATE/DELETE dans le bon ordre topologique selon les dépendances entre entités.

Conséquence pratique : modifier une propriété d'une entité managée suffit, pas besoin de rappeler persist(). À l'inverse, oublier flush() ne fait strictement rien en base, même si les objets PHP semblent à jour.

Le piège des collections détachées

Une entité récupérée dans une requête puis modifiée après un clear() ou en dehors du cycle de vie du manager devient détachée : ses changements ne sont plus suivis. Il faut alors la re-fusionner explicitement, ou mieux, restructurer le code pour rester dans une seule unité de travail cohérente.

Lazy loading et proxies : attention au N+1

Par défaut, les associations ManyToOne/OneToMany sont chargées en lazy via des proxies générés dynamiquement (classes enfants qui interceptent le premier accès pour déclencher une requête). C'est pratique, mais source classique du problème N+1 : itérer sur 100 articles et accéder à $article->getAuteur()->getNom() déclenche 100 requêtes supplémentaires.

La solution est le eager fetching ciblé via DQL :

$qb = $em->createQueryBuilder();
$qb->select('a', 'auteur')
   ->from(Article::class, 'a')
   ->join('a.auteur', 'auteur');

Le double select('a', 'auteur') est essentiel : sans lui, la jointure SQL existe mais Doctrine n'hydrate que Article, et l'accès à l'auteur redéclenche une requête lazy.

Bonnes pratiques d'audit

  • Activer le SQL Logger ou le profiler Symfony pour repérer les requêtes dupliquées en boucle.
  • Préférer des DTO/projections (SELECT NEW en DQL) pour les listes en lecture seule, plutôt que d'hydrater des entités complètes.
  • Utiliser Criteria sur les collections pour filtrer sans forcer un chargement complet.