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.

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.
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.
// 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.
// 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<int, Comment>
*/
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.
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.
// 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<int, Tag>
*/
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;
}
}// 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<int, Article>
*/
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.
Prêt à réussir tes entretiens Symfony ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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.
// 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.
// 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.
// 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.
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.
// 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;
}
}// 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.
// 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
// 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
// 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();
}
}Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Bonnes pratiques et patterns avancés
Initialisation correcte des collections
Toujours initialiser les collections dans le constructeur pour éviter les erreurs de type.
// 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.
// 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
// 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.
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
Tags
Partager
Articles similaires

Questions d'entretien Symfony : Top 25 en 2026
Les 25 questions d'entretien Symfony les plus posées. Architecture, Doctrine ORM, services, sécurité, formulaires et tests avec réponses détaillées et exemples de code.

Symfony 7 : API Platform et bonnes pratiques
Guide complet pour créer des APIs REST professionnelles avec Symfony 7 et API Platform 4. State Providers, Processors, validation et sérialisation expliqués.

Symfony Live Components et UX 3.0 : Applications Réactives Sans JavaScript en 2026
Guide complet des Symfony Live Components et UX 3.0. Découvrez comment créer des interfaces réactives puissantes uniquement avec PHP et Twig, sans framework JavaScript.