# Doctrine ORM : Maîtriser les relations en Symfony > Guide complet des relations Doctrine ORM dans Symfony. OneToMany, ManyToMany, stratégies de chargement et optimisation des performances avec exemples pratiques. - Published: 2026-02-17 - Updated: 2026-03-28 - Author: SharpSkill - Tags: doctrine, symfony, orm, php, database - Reading time: 18 min --- Les relations entre entités constituent le cœur de toute application Symfony utilisant Doctrine ORM. Une maîtrise solide des différents types de relations, des stratégies de chargement et des pièges de performance permet de construire des applications robustes et performantes. Ce guide couvre les patterns essentiels pour gérer efficacement les relations Doctrine. > **Principe fondamental** > > Doctrine utilise le concept d'entités propriétaires (owning side) et inversées (inverse side). Comprendre cette distinction évite de nombreux bugs liés à la persistance des relations. ## Les types de relations Doctrine Doctrine propose quatre types de relations entre entités : OneToOne, OneToMany, ManyToOne et ManyToMany. Chaque type répond à un besoin métier spécifique et possède ses propres caractéristiques de performance. ### Relation ManyToOne : le cas le plus courant La relation ManyToOne représente le lien le plus fréquent en base de données. Plusieurs entités d'un côté se rattachent à une seule entité de l'autre côté. Cette relation est toujours propriétaire de l'association. ```php // src/Entity/Comment.php // Un commentaire appartient à un seul article namespace App\Entity; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: CommentRepository::class)] class Comment { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(type: 'text')] private string $content; // ManyToOne : plusieurs commentaires → un article // Cette entité est propriétaire de la relation (clé étrangère ici) #[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'comments')] #[ORM\JoinColumn(nullable: false)] // La relation est obligatoire private Article $article; #[ORM\Column] private \DateTimeImmutable $createdAt; public function __construct() { $this->createdAt = new \DateTimeImmutable(); } public function getArticle(): Article { return $this->article; } public function setArticle(Article $article): self { $this->article = $article; return $this; } } ``` L'entité `Comment` possède la clé étrangère `article_id`. Doctrine gère automatiquement la persistance : modifier `$comment->setArticle($article)` suffit pour créer le lien en base. ### Relation OneToMany : le côté inverse La relation OneToMany représente le côté inverse d'une ManyToOne. Elle permet de naviguer depuis l'entité "un" vers les "plusieurs". Cette relation n'est jamais propriétaire et ne contient pas de clé étrangère. ```php // src/Entity/Article.php // Un article possède plusieurs commentaires namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ArticleRepository::class)] class Article { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private string $title; #[ORM\Column(type: 'text')] private string $content; // OneToMany : un article → plusieurs commentaires // Côté inverse : mappedBy pointe vers la propriété de l'entité propriétaire #[ORM\OneToMany( targetEntity: Comment::class, mappedBy: 'article', cascade: ['persist', 'remove'], // Cascade les opérations orphanRemoval: true // Supprime les commentaires orphelins )] private Collection $comments; public function __construct() { // Initialiser la collection dans le constructeur $this->comments = new ArrayCollection(); } /** * @return Collection */ public function getComments(): Collection { return $this->comments; } public function addComment(Comment $comment): self { if (!$this->comments->contains($comment)) { $this->comments->add($comment); // CRITIQUE : synchroniser le côté propriétaire $comment->setArticle($this); } return $this; } public function removeComment(Comment $comment): self { if ($this->comments->removeElement($comment)) { // orphanRemoval gère la suppression } return $this; } } ``` Les méthodes `addComment()` et `removeComment()` assurent la cohérence bidirectionnelle. Sans la ligne `$comment->setArticle($this)`, la relation ne serait pas persistée correctement. > **Piège fréquent** > > Oublier de synchroniser les deux côtés d'une relation bidirectionnelle est la source d'erreur la plus courante. Toujours modifier le côté propriétaire pour garantir la persistance. ## Relation ManyToMany : associations multiples La relation ManyToMany connecte plusieurs entités des deux côtés. Doctrine crée automatiquement une table de jointure. Un côté doit être désigné comme propriétaire via l'attribut `inversedBy`. ```php // src/Entity/Article.php // Un article peut avoir plusieurs tags namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: ArticleRepository::class)] class Article { // ... autres propriétés // ManyToMany : côté propriétaire (inversedBy) // Table de jointure : article_tag (générée automatiquement) #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')] #[ORM\JoinTable(name: 'article_tag')] // Nom explicite de la table private Collection $tags; public function __construct() { $this->tags = new ArrayCollection(); } /** * @return Collection */ public function getTags(): Collection { return $this->tags; } public function addTag(Tag $tag): self { if (!$this->tags->contains($tag)) { $this->tags->add($tag); // Côté propriétaire : pas besoin de synchroniser l'autre côté // pour la persistance, mais recommandé pour la cohérence mémoire $tag->addArticle($this); } return $this; } public function removeTag(Tag $tag): self { if ($this->tags->removeElement($tag)) { $tag->removeArticle($this); } return $this; } } ``` ```php // src/Entity/Tag.php // Un tag peut appartenir à plusieurs articles namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: TagRepository::class)] class Tag { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 50, unique: true)] private string $name; // Côté inverse : mappedBy pointe vers le propriétaire #[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')] private Collection $articles; public function __construct() { $this->articles = new ArrayCollection(); } /** * @return Collection */ public function getArticles(): Collection { return $this->articles; } public function addArticle(Article $article): self { if (!$this->articles->contains($article)) { $this->articles->add($article); } return $this; } public function removeArticle(Article $article): self { $this->articles->removeElement($article); return $this; } } ``` Pour une relation ManyToMany avec des données supplémentaires (date d'association, ordre, etc.), convertir en deux relations ManyToOne vers une entité intermédiaire. ## Stratégies de chargement et performance Le chargement des relations constitue le point critique de performance avec Doctrine. Trois stratégies existent : LAZY (par défaut), EAGER et EXTRA_LAZY. ### Lazy Loading : chargement à la demande Le lazy loading charge les relations uniquement lors de leur premier accès. Cette stratégie par défaut évite les requêtes inutiles mais peut créer le problème N+1. ```php // src/Repository/ArticleRepository.php // Démonstration du problème N+1 namespace App\Repository; use App\Entity\Article; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class ArticleRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Article::class); } // PROBLÈME N+1 : une requête par article pour les commentaires public function findAllWithLazyComments(): array { // Requête 1 : SELECT * FROM article $articles = $this->findAll(); // Dans la vue ou le service : // foreach ($articles as $article) { // $article->getComments(); // Requête N : SELECT * FROM comment WHERE article_id = ? // } return $articles; } } ``` Pour 100 articles, ce code génère 101 requêtes SQL : une pour les articles, puis une par article pour charger les commentaires. ### Eager Loading avec jointures Le chargement anticipé résout le problème N+1 en récupérant les relations dans la même requête via des jointures. ```php // src/Repository/ArticleRepository.php // Chargement optimisé avec jointures namespace App\Repository; use App\Entity\Article; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class ArticleRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Article::class); } // SOLUTION : jointure explicite avec fetch join public function findAllWithComments(): array { return $this->createQueryBuilder('a') // LEFT JOIN charge les articles même sans commentaires ->leftJoin('a.comments', 'c') // addSelect inclut les commentaires dans le résultat ->addSelect('c') // Tri par date de création des commentaires ->orderBy('c.createdAt', 'DESC') ->getQuery() ->getResult(); // Une seule requête SQL avec JOIN } // Chargement de plusieurs relations public function findAllWithCommentsAndTags(): array { return $this->createQueryBuilder('a') ->leftJoin('a.comments', 'c') ->addSelect('c') ->leftJoin('a.tags', 't') ->addSelect('t') ->leftJoin('c.author', 'ca') // Auteur du commentaire ->addSelect('ca') ->getQuery() ->getResult(); } } ``` L'utilisation de `addSelect()` après chaque `leftJoin()` est essentielle. Sans `addSelect()`, Doctrine effectue la jointure mais ne charge pas les entités liées. ### Extra Lazy : optimisation des grandes collections La stratégie EXTRA_LAZY optimise les opérations sur les grandes collections sans charger tous les éléments. ```php // src/Entity/Category.php // Catégorie avec potentiellement des milliers de produits namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: CategoryRepository::class)] class Category { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 100)] private string $name; // EXTRA_LAZY : optimise count(), contains(), slice() #[ORM\OneToMany( targetEntity: Product::class, mappedBy: 'category', fetch: 'EXTRA_LAZY' // Ne charge pas tous les produits )] private Collection $products; public function __construct() { $this->products = new ArrayCollection(); } // count() exécute SELECT COUNT(*) au lieu de charger la collection public function getProductCount(): int { return $this->products->count(); // SQL : SELECT COUNT(*) FROM product WHERE category_id = ? } // contains() vérifie l'existence sans tout charger public function hasProduct(Product $product): bool { return $this->products->contains($product); // SQL : SELECT 1 FROM product WHERE id = ? AND category_id = ? } // slice() charge uniquement une portion public function getRecentProducts(int $limit = 5): array { return $this->products->slice(0, $limit); // SQL : SELECT * FROM product WHERE category_id = ? LIMIT 5 } } ``` EXTRA_LAZY évite de charger des milliers d'entités pour une simple vérification ou un comptage. > **Quand utiliser EXTRA_LAZY** > > Appliquer EXTRA_LAZY aux collections potentiellement volumineuses (> 100 éléments) où les opérations count(), contains() ou slice() sont fréquentes. ## Cascade et gestion du cycle de vie Les options de cascade automatisent la propagation des opérations sur les entités liées. Trois options principales : persist, remove et orphanRemoval. ### Cascade Persist : persistance automatique La cascade persist enregistre automatiquement les nouvelles entités liées lors du flush. ```php // src/Entity/Order.php // Commande avec lignes de commande en cascade namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: OrderRepository::class)] #[ORM\Table(name: '`order`')] // order est un mot réservé SQL class Order { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 50)] private string $reference; // cascade persist : les OrderLine sont persistées avec Order #[ORM\OneToMany( targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist'] // Persiste automatiquement les lignes )] private Collection $lines; public function __construct() { $this->lines = new ArrayCollection(); $this->reference = 'ORD-' . uniqid(); } public function addLine(OrderLine $line): self { if (!$this->lines->contains($line)) { $this->lines->add($line); $line->setOrder($this); } return $this; } } ``` ```php // src/Service/OrderService.php // Création de commande avec cascade persist namespace App\Service; use App\Entity\Order; use App\Entity\OrderLine; use App\Entity\Product; use Doctrine\ORM\EntityManagerInterface; class OrderService { public function __construct( private readonly EntityManagerInterface $em ) {} public function createOrder(array $cartItems): Order { $order = new Order(); foreach ($cartItems as $item) { $line = new OrderLine(); $line->setProduct($item['product']); $line->setQuantity($item['quantity']); $line->setUnitPrice($item['product']->getPrice()); // addLine synchronise la relation $order->addLine($line); } // Seul Order est persisté explicitement // Les OrderLine sont persistées automatiquement (cascade) $this->em->persist($order); $this->em->flush(); return $order; } } ``` ### Cascade Remove et OrphanRemoval La cascade remove supprime les entités liées. OrphanRemoval va plus loin en supprimant les entités détachées de la collection. ```php // src/Entity/BlogPost.php // Article de blog avec images namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: BlogPostRepository::class)] class BlogPost { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private string $title; #[ORM\OneToMany( targetEntity: Image::class, mappedBy: 'blogPost', cascade: ['persist', 'remove'], // Supprime les images avec l'article orphanRemoval: true // Supprime aussi les images détachées )] private Collection $images; public function __construct() { $this->images = new ArrayCollection(); } public function removeImage(Image $image): self { if ($this->images->removeElement($image)) { // orphanRemoval : l'image sera supprimée au flush // Sans orphanRemoval : l'image resterait en base sans blogPost } return $this; } public function clearImages(): self { // Vide la collection → toutes les images seront supprimées $this->images->clear(); return $this; } } ``` La différence clé : `cascade: ['remove']` supprime les entités liées uniquement lors de la suppression de l'entité parente. `orphanRemoval: true` supprime également les entités retirées de la collection. ## Requêtes DQL avancées pour les relations Le DQL (Doctrine Query Language) offre une flexibilité maximale pour les requêtes impliquant des relations complexes. ### Filtrage sur les relations ```php // src/Repository/ArticleRepository.php // Requêtes avancées sur les relations namespace App\Repository; use App\Entity\Article; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class ArticleRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Article::class); } // Articles avec au moins un commentaire public function findWithComments(): array { return $this->createQueryBuilder('a') ->innerJoin('a.comments', 'c') // INNER JOIN exclut les articles sans commentaires ->addSelect('c') ->groupBy('a.id') ->getQuery() ->getResult(); } // Articles par tag avec comptage de commentaires public function findByTagWithCommentCount(string $tagName): array { return $this->createQueryBuilder('a') ->select('a', 'COUNT(c.id) as commentCount') ->leftJoin('a.comments', 'c') ->innerJoin('a.tags', 't') ->where('t.name = :tagName') ->setParameter('tagName', $tagName) ->groupBy('a.id') ->orderBy('commentCount', 'DESC') ->getQuery() ->getResult(); } // Articles récents avec auteurs des commentaires public function findRecentWithAuthors(\DateTimeInterface $since): array { return $this->createQueryBuilder('a') ->leftJoin('a.comments', 'c') ->addSelect('c') ->leftJoin('c.author', 'u') // Jointure sur l'auteur du commentaire ->addSelect('u') ->where('a.publishedAt > :since') ->setParameter('since', $since) ->orderBy('a.publishedAt', 'DESC') ->getQuery() ->getResult(); } } ``` ### Sous-requêtes et agrégations ```php // src/Repository/UserRepository.php // Requêtes complexes avec sous-requêtes namespace App\Repository; use App\Entity\User; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class UserRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, User::class); } // Utilisateurs les plus actifs (par nombre d'articles) public function findMostActiveAuthors(int $limit = 10): array { return $this->createQueryBuilder('u') ->select('u', 'COUNT(a.id) as articleCount') ->leftJoin('u.articles', 'a') ->where('a.status = :published') ->setParameter('published', 'published') ->groupBy('u.id') ->having('COUNT(a.id) > 0') // HAVING pour filtrer après GROUP BY ->orderBy('articleCount', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); } // Utilisateurs avec articles ayant plus de 10 commentaires public function findAuthorsWithPopularArticles(): array { // Sous-requête DQL $em = $this->getEntityManager(); $subQuery = $em->createQueryBuilder() ->select('IDENTITY(a2.author)') ->from('App\Entity\Article', 'a2') ->leftJoin('a2.comments', 'c2') ->groupBy('a2.id') ->having('COUNT(c2.id) > 10') ->getDQL(); return $this->createQueryBuilder('u') ->where('u.id IN (' . $subQuery . ')') ->getQuery() ->getResult(); } } ``` ## Bonnes pratiques et patterns avancés ### Initialisation correcte des collections Toujours initialiser les collections dans le constructeur pour éviter les erreurs de type. ```php // src/Entity/Author.php // Initialisation propre des relations namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; #[ORM\Entity(repositoryClass: AuthorRepository::class)] class Author { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\OneToMany(targetEntity: Book::class, mappedBy: 'author')] private Collection $books; #[ORM\ManyToMany(targetEntity: Genre::class)] private Collection $favoriteGenres; // TOUJOURS initialiser dans le constructeur public function __construct() { $this->books = new ArrayCollection(); $this->favoriteGenres = new ArrayCollection(); } // Méthode utilitaire pour vérifier si la collection est chargée public function areBooksLoaded(): bool { return $this->books->isInitialized(); } } ``` ### Éviter les références circulaires Les relations bidirectionnelles peuvent créer des boucles infinies lors de la sérialisation. ```php // src/Entity/Department.php // Gestion des références circulaires namespace App\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\MaxDepth; #[ORM\Entity(repositoryClass: DepartmentRepository::class)] class Department { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] #[Groups(['department:read'])] private ?int $id = null; #[ORM\Column(length: 100)] #[Groups(['department:read', 'employee:read'])] private string $name; // MaxDepth limite la profondeur de sérialisation #[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'department')] #[Groups(['department:read'])] #[MaxDepth(1)] // Ne sérialise pas les relations des employés private Collection $employees; public function __construct() { $this->employees = new ArrayCollection(); } } ``` ### Pattern Repository avec critères dynamiques ```php // src/Repository/ProductRepository.php // Critères de recherche flexibles namespace App\Repository; use App\Entity\Product; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; class ProductRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Product::class); } // Recherche avec filtres optionnels public function findByCriteria(array $criteria): array { $qb = $this->createQueryBuilder('p') ->leftJoin('p.category', 'c') ->addSelect('c') ->leftJoin('p.tags', 't') ->addSelect('t'); // Filtres conditionnels if (isset($criteria['category'])) { $qb->andWhere('c.slug = :category') ->setParameter('category', $criteria['category']); } if (isset($criteria['minPrice'])) { $qb->andWhere('p.price >= :minPrice') ->setParameter('minPrice', $criteria['minPrice']); } if (isset($criteria['maxPrice'])) { $qb->andWhere('p.price <= :maxPrice') ->setParameter('maxPrice', $criteria['maxPrice']); } if (isset($criteria['tags']) && is_array($criteria['tags'])) { $qb->andWhere('t.name IN (:tags)') ->setParameter('tags', $criteria['tags']); } if (isset($criteria['inStock']) && $criteria['inStock']) { $qb->andWhere('p.stock > 0'); } return $qb->orderBy('p.createdAt', 'DESC') ->getQuery() ->getResult(); } } ``` ## Conclusion La maîtrise des relations Doctrine ORM repose sur quelques principes fondamentaux : ✅ **Côté propriétaire vs inverse** : toujours modifier le côté propriétaire pour garantir la persistance ✅ **Synchronisation bidirectionnelle** : les méthodes add/remove doivent synchroniser les deux côtés ✅ **Fetch joins** : utiliser `addSelect()` après chaque jointure pour éviter le problème N+1 ✅ **EXTRA_LAZY** : activer sur les collections volumineuses pour optimiser count() et contains() ✅ **Cascade avec précaution** : persist souvent utile, remove et orphanRemoval selon le contexte métier ✅ **Initialisation des collections** : toujours dans le constructeur avec ArrayCollection Ces patterns constituent la base d'une application Symfony performante. L'étape suivante consiste à maîtriser les index et les requêtes natives pour les cas de performance extrême. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/symfony/doctrine-orm-maitriser-relations