Doctrine ORM — UnitOfWork, IdentityMap, relations
TL;DR — Doctrine is a Data Mapper (pas Active Record). Le
EntityManagerorchestre uneUnitOfWorkqui suit chaque objet géré via uneIdentityMap.flush()calcule un changeset et émet SQL en batch. Maîtriser fetch modes, cascades et DQL vs QueryBuilder = la différence entre 3 requêtes et 3000.
Mental model — ASCII diagram + analogy
Analogie : Doctrine = un comptable qui observe vos objets PHP. Il prend des photos (snapshots) au persist() et compare au moment du flush() pour générer les INSERT/UPDATE/DELETE.
┌─────────────────────────────────────────┐
│ EntityManager │
│ │
│ ┌──────────────────────────────────┐ │
│ │ UnitOfWork │ │
│ │ │ │
│ │ IdentityMap Scheduled Ops │ │
│ │ {class#id: $e} {inserts:[..], │ │
│ │ updates:[..], │ │
│ │ deletes:[..]} │ │
│ │ │ │
│ │ OriginalEntityData (snapshots) │ │
│ └──────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ClassMetadata │
│ (mapping attributes) │
└──────────────────┬──────────────────────┘
│
▼
DBAL Connection
│
▼
DatabaseRègle d'or : find() consulte d'abord l'IdentityMap → un même id = même instance PHP dans une requête HTTP.
Code minimal — realistic snippet (PHP 8.2+)
<?php
// src/Entity/Author.php
namespace App\Entity;
use App\Repository\AuthorRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AuthorRepository::class)]
#[ORM\Table(name: 'authors')]
#[ORM\Index(columns: ['email'], name: 'idx_author_email')]
class Author
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 180, unique: true)]
private string $email;
/** @var Collection<int, Book> */
#[ORM\OneToMany(
mappedBy: 'author',
targetEntity: Book::class,
cascade: ['persist'],
fetch: 'EXTRA_LAZY',
orphanRemoval: true,
)]
private Collection $books;
public function __construct(string $email)
{
$this->email = $email;
$this->books = new ArrayCollection();
}
public function addBook(Book $book): void
{
if (!$this->books->contains($book)) {
$this->books->add($book);
$book->setAuthor($this);
}
}
public function getId(): ?int { return $this->id; }
public function getBooks(): Collection { return $this->books; }
}<?php
// src/Entity/Book.php
namespace App\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Book
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\ManyToOne(inversedBy: 'books', fetch: 'LAZY')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
private Author $author;
/** @var Collection<int, Tag> */
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'books')]
#[ORM\JoinTable(name: 'book_tag')]
private Collection $tags;
public function __construct(string $title)
{
$this->title = $title;
// OBLIGATOIRE : sans init, $book->addTag() sur une entité fraîche
// lève "Typed property must not be accessed before initialization".
$this->tags = new ArrayCollection();
}
public function setAuthor(Author $author): void { $this->author = $author; }
}Détail qui pique en prod : sur une
ManyToOnenon-nullable (Author $authortypée non-nullable, sans= null), oubliersetAuthor()avant leflush()lève uneTypeErrorPHP avant même d'atteindre la base. Pour une relation optionnelle, typez?Author $author = nullet laissez Doctrine écrireNULL.
<?php
// src/Repository/AuthorRepository.php
namespace App\Repository;
use App\Entity\Author;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/** @extends ServiceEntityRepository<Author> */
class AuthorRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Author::class);
}
/** @return Author[] */
public function findWithBooksByDomain(string $domain): array
{
return $this->createQueryBuilder('a')
->select('a', 'b') // SELECT partiel = N+1 trap, on ramène les 2
->leftJoin('a.books', 'b') // fetch join
->where('a.email LIKE :d')
->setParameter('d', "%@$domain")
->getQuery()
->getResult();
}
}Sous le capot — ce qu'un flush() exécute réellement
Un staff engineer ne voit pas flush() comme une boîte noire. Voici la séquence exacte de la UnitOfWork::commit() :
computeChangeSets()— pour chaque entité managée, Doctrine compare champ par champ l'état actuel à l'OriginalEntityData(le snapshot pris aufind()/persist()). Le résultat est unchangeSet:['email' => [ancien, nouveau]]. Conséquence clé : une entité jamais modifiée ne génère aucun UPDATE, mais le coût du diff est O(entités managées × champs). D'où l'intérêt declear()dans les batchs.- Calcul de l'ordre d'insertion (
CommitOrderCalculator) — Doctrine construit un graphe de dépendances entre tables (clés étrangères) et le trie topologiquement. C'est pourquoi vous pouvezpersist($book)avantpersist($author): Doctrine réordonne pour respecter les FK. - Exécution dans l'ordre :
INSERT(parents → enfants), puisUPDATE, puisDELETE(enfants → parents), puis les écritures de tables de jointction ManyToMany. Le tout dans une seule transaction ouverte/commitée parflush(). - Post-write : récupération des IDs auto-générés (
lastInsertIdouRETURNING), mise à jour de l'IdentityMap, rafraîchissement des snapshots. Les entités passent deSCHEDULEDàMANAGED.
persist($a); persist($b); → IdentityMap (rien en base encore)
$em->flush()
├─ computeChangeSets() → diff vs snapshots
├─ CommitOrderCalculator → tri topologique des FK
├─ BEGIN
│ INSERT authors ... → id récupéré, injecté dans $a
│ INSERT books ... → FK author_id résolue
│ INSERT book_tag ...
├─ COMMIT
└─ snapshots rafraîchis → un 2ᵉ flush() sans modif = 0 SQLStratégies d'ID — l'impact caché sur le batching
| Stratégie | SQL généré | Batch INSERT possible ? |
|---|---|---|
IDENTITY (auto-increment MySQL) | INSERT immédiat au persist() pour obtenir l'ID | ❌ pas de regroupement multi-rows |
SEQUENCE (PostgreSQL) | nextval puis INSERT au flush | ✅ Doctrine peut allouer un bloc d'IDs |
UUID (Uuid::v7(), généré côté PHP) | INSERT au flush, ID connu avant | ✅ idéal pour le batching et le sharding |
Sur PostgreSQL/UUID, flush() peut grouper les écritures ; sur MySQL IDENTITY, chaque persist() force un aller-retour DB immédiat. C'est un argument réel pour les UUID v7 (ordonnés temporellement, donc index-friendly) sur les domaines à fort volume d'insertion.
Concurrence — optimistic vs pessimistic locking
flush() n'est pas magique face aux écritures concurrentes (lost update). Deux outils :
// Optimistic : une colonne version est comparée au COMMIT.
#[ORM\Version]
#[ORM\Column(type: 'integer')]
private int $version = 1;
// → UPDATE ... WHERE id = ? AND version = ? ; 0 ligne affectée = OptimisticLockException// Pessimistic : verrou SQL posé à la lecture (SELECT ... FOR UPDATE).
$dossier = $em->find(Dossier::class, $id, LockMode::PESSIMISTIC_WRITE);Mental model : optimistic = « je parie qu'il n'y aura pas de conflit, je vérifie au commit » (lecture scalable, retry applicatif si collision). Pessimistic = « je bloque la ligne tant que je travaille » (sérialise, risque de deadlock, à réserver aux sections courtes type décrément de stock). En pratique : optimistic par défaut, pessimistic uniquement sur les invariants où une collision est probable et coûteuse.
Patterns courants — 3–6 patterns
- Fetch join contre N+1 :
leftJoin('a.books', 'b')->addSelect('b')charge en une requête. - Owning vs inverse side : la side avec
JoinColumn(ouJoinTable) est owning. Doctrine ne persiste que ce côté → si la collection inverse n'est pas synchronisée à la main, l'autre côté n'est pas écrit. - Partial DTO via
selectscalar :->select('a.id', 'a.email')->getArrayResult()évite l'hydratation d'entités pour des lectures massives. - Batch processing : boucle de 1000 →
flush()+clear()pour éviter la consommation mémoire (IdentityMap qui explose). EXTRA_LAZYsur OneToMany :$author->getBooks()->count()fait unCOUNT(*)SQL au lieu d'hydrater la collection.- Read model séparé : pour les pages de liste, requête SQL pure via DBAL ou QueryBuilder → array, jamais d'entité.
Versions — Symfony 5.4 / 6.4 / 7.x
| Topic | 5.4 / Doctrine ORM 2.x | 6.4+ / Doctrine ORM 3.x |
|---|---|---|
| Mapping | Annotations + attributes | Attributes only (annotations dropped) |
EntityManager interface | ObjectManager ok | Préférer EntityManagerInterface |
ClassMetadataInfo | OK | Renommé ClassMetadata |
getReference() | Renvoie proxy même si entité absente | Validation plus stricte (cf. getPartialReference retiré) |
| Embeddables | Stables | Stables, mais init obligatoire |
NotifyPropertyChanged | Supporté | Toujours supporté mais déconseillé |
- Doctrine ORM 3 : drop PHP 7, signatures typées strictes,
Doctrine\ORM\Query\Exprenrichi,AbstractQuery::toIterable()remplaceiterate(). - DBAL 4 :
fetchAssociative(),fetchAllAssociative()à la place defetch()/fetchAll()historiques.
Pitfalls — 5–8 concrete traps
- N+1 silencieux : itération sur
$author->getBooks()sans fetch join → 1 + N requêtes. Profiler obligatoire. - Cascade persist surprise :
cascade: ['persist']propage à chaque flush. Sur un graphe profond, unpersist($root)peut tenter d'écrire 50 entités. flush()global :$em->flush()sans argument écrit toutes les entités gérées, pas seulement la dernière. Sur Symfony moderne,flush($entity)est deprecated.- Owning side oubliée :
$book->setAuthor($a)mais pas$a->addBook($b)→ côté inverse pas en mémoire jusqu'aurefresh(). getReference()sur ID inexistant : crée un proxy ; le crash arrive plus tard à l'accès, traçable très difficilement.- Lazy loading dans une boucle Twig : un filtre de longueur sur une collection lazy à l'intérieur d'une boucle peut déclencher du SQL à chaque itération. Exemple de template fautif :
{# ❌ une requête COUNT par auteur si la collection n'est pas EXTRA_LAZY/pré-chargée #}
{% for author in authors %}
{{ author.books|length }}
{% endfor %}Activer le profiler en dev et préférer EXTRA_LAZY (qui transforme |length en COUNT(*)) ou un fetch join. 7. cascade: ['remove'] + soft delete : conflits avec gedmo/softdeleteable ou listeners → suppressions fantômes. 8. merge() (ORM 2) : disparu en ORM 3 ; toute logique basée dessus à réécrire avec find() + setters. 9. Détachement / clear : $em->clear() invalide tous les objets gérés ; les objets manipulés après deviennent "detached" → persist() lèvera une exception sur certaines stratégies.
Testing — phpunit / KernelTestCase
<?php
namespace App\Tests\Repository;
use App\Entity\Author;
use App\Repository\AuthorRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
final class AuthorRepositoryTest extends KernelTestCase
{
private EntityManagerInterface $em;
private AuthorRepository $repo;
protected function setUp(): void
{
self::bootKernel();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->repo = $container->get(AuthorRepository::class);
$this->em->beginTransaction();
}
protected function tearDown(): void
{
$this->em->rollback(); // isolation par test
$this->em->close();
parent::tearDown();
}
public function testFetchJoinAvoidsNPlusOne(): void
{
$a = new Author('[email protected]');
$this->em->persist($a);
$this->em->flush();
$this->em->clear();
// DBAL 4 : DebugStack/setSQLLogger sont SUPPRIMÉS. On compte les requêtes
// soit via le DataCollector du profiler, soit via un middleware de test.
$before = $this->countQueries();
$authors = $this->repo->findWithBooksByDomain('acme.io');
foreach ($authors as $author) {
$author->getBooks()->toArray(); // ne doit PAS déclencher de SELECT supplémentaire
}
self::assertSame(1, $this->countQueries() - $before, 'Should hit DB only once');
}
private function countQueries(): int
{
// En test fonctionnel Symfony, le profiler expose un collector "db".
/** @var \Symfony\Bundle\DoctrineBundle\DataCollector\DoctrineDataCollector $c */
$c = static::getContainer()->get('doctrine.data_collector');
return $c->getQueryCount();
}
}En CI sans profiler, la voie idiomatique DBAL 4 est un middleware (
Doctrine\DBAL\Driver\Middleware) qui décore leDriveret incrémente un compteur à chaqueprepare()/query(). C'est le mécanisme qu'utilise désormaisDoctrineBundleen interne là où il s'appuyait surSQLLogger.
🎬 Cas d'usage concrets
Scénario 1 — DMS cabinet juridique avec relations riches
Un cabinet d'avocats parisien gère 12 000 dossiers actifs, chacun rattaché à un client (personne physique ou morale), une matière (droit social, droit des affaires, contentieux), une équipe d'associés et de collaborateurs, et une arborescence documentaire. Doctrine ORM modélise ces relations avec un agrégat Dossier racine qui contient des Document (composition), référence un Client (association), et expose une collection Intervenant typée par rôle. Les invariants métier — un dossier ne peut être clôturé que si toutes les factures sont réglées, un document confidentiel ne peut être détaché de son dossier — sont protégés dans les entités elles-mêmes, jamais dans les contrôleurs. L'équipe utilise les annotations #[ORM\OneToMany] avec cascade: ['persist'] pour les documents et fetch: 'EXTRA_LAZY' pour les pièces volumineuses afin d'éviter de hydrater 5 000 PDF lors de l'ouverture d'un dossier. Les Embeddable modélisent les adresses des clients et les coordonnées bancaires, évitant la prolifération de colonnes plates. La portabilité DQL leur permet de basculer du PostgreSQL de prod vers SQLite en CI sans réécrire une seule requête.
Scénario 2 — ERP industriel pour fabricant de pièces aéronautiques
Une PME industrielle de 400 salariés exploite un ERP maison construit sur Symfony 7 et Doctrine ORM. Les entités centrales sont OrdreFabrication, Article, Nomenclature (BOM hiérarchique avec self-reference), Lot (traçabilité matière) et Mouvement (entrée/sortie stock). La hiérarchie BOM exploite une self-association parent/children sur Nomenclature, parcourue avec des requêtes récursives DQL pour calculer le coût total d'un article composé. Les invariants métier — un ordre ne peut passer en production que si toutes les matières sont disponibles, un lot ne peut être consommé qu'une fois — sont validés via des méthodes de domaine (OrdreFabrication::lancerProduction()) qui lèvent des exceptions typées. Les OneToMany entre OrdreFabrication et Mouvement utilisent orphanRemoval: true pour garantir qu'un mouvement orphelin disparaît avec son ordre. La traçabilité réglementaire (norme EN 9100) impose un horodatage immuable sur chaque mouvement, géré par un LifecycleCallback prePersist.
Scénario 3 — Catalogue e-commerce hiérarchique multi-marketplace
Une marketplace française de mobilier design distribue 80 000 références à travers 600 marchands. Le catalogue Doctrine ORM modélise une hiérarchie Categorie (matériel-jardin, salons-extérieurs, chaises-longues) avec extension Gedmo Tree pour le pattern nested-set, permettant des requêtes "tous les produits sous la catégorie X et ses descendants" en une seule requête SQL. Chaque Produit est lié à un Marchand, possède plusieurs Variante (taille, couleur), et expose une collection de Photo triée par position. Les requêtes critiques de listing utilisent addSelect pour pré-charger les variantes et photos en une seule requête, évitant le N+1. La denormalisation contrôlée — un champ prixMinimum calculé via lifecycle callback sur Produit à partir de ses variantes — accélère le tri par prix sans recourir à un index externe. L'équipe maintient une couverture de tests fonctionnels avec PHPUnit et SQLite en mémoire, ce qui garantit que les invariants de l'agrégat catalogue restent verts à chaque PR.
🛠️ Exemple end-to-end
Use case : dans le DMS du cabinet juridique, on doit charger un dossier complet (client, intervenants, documents non archivés) en évitant le N+1, puis clôturer le dossier en respectant l'invariant "aucune facture impayée".
<?php
// src/Domain/Dossier/Entity/Dossier.php
declare(strict_types=1);
namespace App\Domain\Dossier\Entity;
use App\Domain\Dossier\Exception\DossierNonClotureableException;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
#[ORM\Entity(repositoryClass: DossierRepository::class)]
#[ORM\Table(name: 'dossier')]
#[ORM\Index(columns: ['client_id', 'statut'], name: 'idx_dossier_client_statut')]
class Dossier
{
#[ORM\Id, ORM\Column(type: 'uuid')]
private Uuid $id;
#[ORM\ManyToOne(targetEntity: Client::class, inversedBy: 'dossiers')]
#[ORM\JoinColumn(nullable: false)]
private Client $client;
#[ORM\Column(length: 20)]
private string $statut = 'OUVERT';
/** @var Collection<int, Document> */
#[ORM\OneToMany(mappedBy: 'dossier', targetEntity: Document::class, cascade: ['persist'], orphanRemoval: true, fetch: 'EXTRA_LAZY')]
private Collection $documents;
/** @var Collection<int, Facture> */
#[ORM\OneToMany(mappedBy: 'dossier', targetEntity: Facture::class)]
private Collection $factures;
public function __construct(Client $client, public readonly string $reference)
{
$this->id = Uuid::v7();
$this->client = $client;
$this->documents = new ArrayCollection();
$this->factures = new ArrayCollection();
}
public function cloturer(\DateTimeImmutable $at): void
{
if ($this->statut !== 'OUVERT') {
throw new DossierNonClotureableException('Dossier déjà clôturé ou archivé');
}
$impayees = $this->factures->filter(fn (Facture $f) => !$f->estReglee())->count();
if ($impayees > 0) {
throw new DossierNonClotureableException(sprintf('%d facture(s) impayée(s)', $impayees));
}
$this->statut = 'CLOTURE';
}
public function getId(): Uuid { return $this->id; }
}
// src/Domain/Dossier/Repository/DossierRepository.php
namespace App\Domain\Dossier\Repository;
use App\Domain\Dossier\Entity\Dossier;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Component\Uid\Uuid;
class DossierRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Dossier::class);
}
public function loadCompletPourAffichage(Uuid $id): ?Dossier
{
return $this->createQueryBuilder('d')
->addSelect('c', 'doc', 'i')
->leftJoin('d.client', 'c')
->leftJoin('d.documents', 'doc', 'WITH', 'doc.archive = false')
->leftJoin('d.intervenants', 'i')
->where('d.id = :id')
->setParameter('id', $id, 'uuid')
->getQuery()
->getOneOrNullResult();
}
}
// src/Application/Dossier/Command/CloturerDossierHandler.php
namespace App\Application\Dossier\Command;
use App\Domain\Dossier\Repository\DossierRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final readonly class CloturerDossierHandler
{
public function __construct(
private DossierRepository $dossiers,
private EntityManagerInterface $em,
) {}
public function __invoke(CloturerDossier $cmd): void
{
$dossier = $this->dossiers->find($cmd->dossierId)
?? throw new \DomainException('Dossier introuvable');
$dossier->cloturer($cmd->at);
$this->em->flush();
}
}Observabilité & production — comment un staff engineer instrumente Doctrine
Le tueur de perf #1 d'une app Doctrine n'est pas une requête lente, c'est le nombre de requêtes. Ce qu'on surveille réellement :
- Query count par requête HTTP : le profiler Symfony (panel Doctrine) en dev, et en prod un budget (ex. « ≥ 50 requêtes sur un endpoint = alerte »). Le
DoctrineDataCollectorexposegetQueryCount()— exploitable dans un middleware Messenger ou un listenerkernel.terminatequi pousse la métrique vers Prometheus/Datadog. - N+1 en CI, pas en prod : faire échouer un test fonctionnel si une page dépasse un seuil de requêtes. C'est plus fiable que l'œil humain sur le profiler.
- Slow query log côté SGBD +
EXPLAIN ANALYZEsur les DQL hydratant beaucoup de jointures. Un fetch join sur deuxOneToManyproduit un produit cartésien (N×M lignes) — souvent plus lent que deux requêtes séparées. Doctrine sait paginer ce cas via lePaginatoravecfetchJoinCollection: true. - Hydration cost : l'hydratation d'entités est coûteuse (instanciation, IdentityMap, snapshots). Pour les écrans de lecture,
getArrayResult()/ DTO viaNEWDQL divise souvent le temps par 3–5.
// DTO hydration : zéro entité managée, idéal pour un read model.
$qb->select('NEW App\Dto\BookListItem(b.id, b.title, a.email)')
->from(Book::class, 'b')
->join('b.author', 'a');
// → array de BookListItem, pas d'IdentityMap, pas de snapshot, pas de N+1 lazy possible.Caches — savoir lequel résout quoi
| Cache | Ce qu'il met en cache | Piège |
|---|---|---|
| Metadata cache | Le mapping (ClassMetadata) parsé | Doit être warmé en prod (cache:warmup), sinon parsing à froid |
| Query cache | Le DQL → SQL traduit (pas le résultat) | Inutile si vous changez le DQL à chaque appel (concaténation) |
| Result cache | Le résultat d'une requête | Invalidation = votre problème ; à réserver aux données quasi-statiques |
| Second-level cache (2LC) | Entités hydratées entre requêtes HTTP | Complexe, region/TTL à régler ; souvent un Redis applicatif est plus simple |
Règle de staff : commencez par tuer le N+1 et hydrater des DTO. Le 2LC est une optimisation de dernier recours, pas un point de départ.
Quand utiliser / éviter
Utiliser Doctrine ORM quand :
- Modèle métier riche, beaucoup d'invariants à protéger.
- Plusieurs SGBD ciblés (portabilité DQL).
- Cycle CRUD classique avec relations.
Éviter quand :
- Pipelines analytics, agrégations massives → DBAL pur ou
\PDO. - Read-heavy lecture seule → CQRS read side avec SQL brut.
- Time series, large blobs → store dédié.
🏋️ Exercices
Chaque exercice escalade : implémenter → niveau production → casser puis réparer. Faites-les sur un projet Symfony 7 réel avec le profiler activé.
1. Tuer un N+1 — niveau implémentation
Objectif : afficher 50 auteurs avec le nombre de leurs livres en exactement 1 requête SQL. Indice/Solution : naïvement, findAll() + boucle sur getBooks()->count() = 1 + 50 requêtes. Passer la collection en fetch: 'EXTRA_LAZY' transforme count() en COUNT(*) (51 → 1+50 COUNT, encore trop), donc préférer un DQL SELECT a, COUNT(b) FROM Author a LEFT JOIN a.books b GROUP BY a.id qui ramène tout en une requête. Vérifier le compteur dans le profiler.
2. Batch import 1M de lignes sans exploser la mémoire — niveau production
Objectif : importer un CSV de 1 000 000 lignes en gardant une RSS < 256 Mo et un débit régulier. Indice/Solution : boucle avec flush() + clear() tous les ~500. Désactiver le SQL logging. Préférer une stratégie d'ID SEQUENCE/UUID (cf. tableau plus haut) pour permettre le regroupement des INSERT — IDENTITY force un aller-retour par ligne. Mesurer : sans clear(), la mémoire croît linéairement (IdentityMap + snapshots) ; avec, elle reste plate. Bonus : comparer au DBAL pur ($conn->insert()), souvent 2× plus rapide car zéro hydratation.
3. Concurrence sur un décrément de stock — niveau production
Objectif : deux requêtes simultanées décrémentent le stock d'un même produit (stock=1) ; garantir qu'une seule réussit. Indice/Solution : sans verrou, les deux lisent stock=1, écrivent 0 → survente (lost update). Ajouter #[ORM\Version] (optimistic) : la 2ᵉ reçoit OptimisticLockException, à retry/refuser applicativement. Variante : LockMode::PESSIMISTIC_WRITE au find() (SELECT ... FOR UPDATE) sérialise les deux. Reproduire la course avec deux process et sleep() entre lecture et écriture.
4. Casser puis réparer — owning side oubliée
Objectif : reproduire le bug « la relation ManyToMany ne se persiste pas », puis le corriger. Indice/Solution : sur Book/Tag, faire $tag->getBooks()->add($book) (côté inverse) puis flush() → rien n'est écrit dans book_tag. Doctrine ne lit que l'owning side (celui qui porte inversedBy/JoinTable). Réparer en mutant le côté owning ($book->addTag($tag)) et en synchronisant les deux collections dans une méthode de domaine. Vérifier dans la base que book_tag reçoit bien la ligne.
5. Casser puis réparer — clear() dans une transaction
Objectif : comprendre pourquoi un objet devient inutilisable après clear(). Indice/Solution : find() un Author, $em->clear(), puis $em->flush() après l'avoir modifié → l'entité est detached, sa modification est perdue (plus dans l'IdentityMap, plus de snapshot). Réparer en re-find() (ou merge() en ORM 2, supprimé en ORM 3) après le clear(). Leçon : clear() est une bombe dans un handler long ; scoper le clear ou recharger explicitement.
6. Cartésien d'un double fetch join — niveau diagnostic
Objectif : montrer qu'un fetch join sur deux OneToMany dégrade les perfs. Indice/Solution : addSelect('books', 'tags') sur un même auteur ramène n_books × n_tags lignes (produit cartésien) — l'hydratation déduplique mais le réseau et la DB souffrent. EXPLAIN ANALYZE le confirme. Réparer en chargeant une seule collection par requête (ou via Paginator/requêtes séparées) ; ne fetch-joiner qu'une collection à la fois.
🎤 En entretien
Q : Pourquoi find() deux fois avec le même ID renvoie-t-il la même instance PHP ? R : Doctrine consulte l'IdentityMap avant la base ; un class#id déjà chargé est servi depuis la mémoire (identité référentielle garantie dans un EM). C'est ce qui rend les comparaisons === fiables et évite les états divergents.
Q : Que fait exactement flush() sans argument ? R : Il déclenche computeChangeSets() sur toutes les entités managées, calcule l'ordre topologique des écritures (FK), puis exécute INSERT/UPDATE/DELETE dans une transaction unique. flush($entity) (scoping) est deprecated — on ne flush plus une entité isolée.
Q : Optimistic ou pessimistic locking, comment choisis-tu ? R : Optimistic (#[ORM\Version]) par défaut — scalable, pas de verrou tenu, retry applicatif si collision. Pessimistic (SELECT ... FOR UPDATE) seulement quand la collision est probable et coûteuse (décrément de stock, réservation), sur une section courte, en acceptant le risque de deadlock.
Q : Data Mapper vs Active Record — pourquoi Doctrine a choisi Data Mapper ? R : Le Data Mapper sépare l'entité (POPO, ignorante de la persistance) du mécanisme de stockage (EntityManager). Résultat : entités testables sans DB, invariants métier dans le domaine, et un EM qui orchestre via UnitOfWork. Active Record (l'entité connaît save()) couple le domaine à l'infra — plus rapide à écrire, plus dur à faire évoluer sur un modèle riche.
Liens
- Doctrine ORM Docs — https://www.doctrine-project.org/projects/orm.html
- Symfony Doctrine — https://symfony.com/doc/current/doctrine.html
- Matthias Noback — "A Year With Symfony" : chapitres ORM toujours d'actualité.
- Article officiel sur UnitOfWork — https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/unitofwork.html
- DQL Reference — https://www.doctrine-project.org/projects/doctrine-orm/en/current/reference/dql-doctrine-query-language.html