Journal

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

Journal du lundi 24 août 2026, généré le 24 août à 09:52

Le mapping par attributs a remplacé les annotations

Depuis Doctrine ORM 2.9+ et surtout la branche 3.x, le mapping se fait avec les attributs natifs PHP 8 plutôt qu'avec les annotations Doctrine (@ORM\...). Les annotations restent lisibles par l'AnnotationReader mais sont dépréciées : tout nouveau code devrait utiliser #[...].

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class Order
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private int $id;

    #[ORM\Column(enumType: OrderStatus::class)]
    private OrderStatus $status;

    #[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderLine::class, cascade: ['persist'])]
    private Collection $lines;
}

Le support natif des enums PHP via enumType évite le bricolage manuel de conversion string/enum qu'il fallait faire avant.

Comprendre le Unit of Work avant d'optimiser

L'EntityManager ne fait rien tant que flush() n'est pas appelé. Entre-temps, le Unit of Work maintient une identity map (une entité = un objet PHP par requête) et calcule les changesets en comparant l'état original de chaque entité à son état actuel. C'est pour cela qu'un simple $order->setStatus(...) suffit : Doctrine détecte la modification au flush, sans appel explicite à persist() pour une entité déjà managée.

Cette comparaison a un coût sur de gros volumes. Pour des imports massifs, préférez le bulk processing : détacher (detach()) ou vider (clear()) l'EntityManager tous les N enregistrements pour éviter que l'identity map ne grossisse indéfiniment.

Le piège N+1 et les fetch joins

Une relation #[OneToMany] est chargée en lazy loading par défaut via un proxy. Boucler sur des commandes puis accéder à $order->getLines() déclenche une requête par commande. La solution n'est pas de passer en fetch: 'EAGER' (qui charge systématiquement, même quand ce n'est pas nécessaire), mais d'utiliser un fetch join explicite dans le DQL :

$qb = $em->createQueryBuilder();
$qb->select('o', 'l')
   ->from(Order::class, 'o')
   ->leftJoin('o.lines', 'l')
   ->where('o.status = :status')
   ->setParameter('status', OrderStatus::Paid);

Le select('o', 'l') est essentiel : sans lui, Doctrine joint la table en SQL mais n'hydrate que les entités Order, et le N+1 réapparaît silencieusement au premier accès aux lignes.

Migrations et policies de suivi des changements

Depuis quelques versions, doctrine/migrations est un paquet totalement séparé de l'ORM, avec sa propre configuration (migrations.php). Pensez aussi à la change tracking policy NOTIFY pour les entités très fréquemment mises à jour en masse : elle évite le coût de comparaison automatique en exigeant que l'entité notifie elle-même ses changements via NotifyPropertyChanged, au prix d'un peu de boilerplate.