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.