# Doctrine ORM: Relaties beheersen in Symfony > Volledige gids voor Doctrine ORM-relaties in Symfony. OneToMany, ManyToMany, laadstrategieën en performance-optimalisatie met praktische voorbeelden. - Published: 2026-02-17 - Updated: 2026-04-28 - Author: SharpSkill - Tags: doctrine, symfony, orm, php, database - Reading time: 18 min --- Relaties tussen entiteiten vormen de ruggengraat van elke Symfony-applicatie die Doctrine ORM gebruikt. Een gedegen begrip van de verschillende relatietypen, laadstrategieën en performance-valkuilen maakt het mogelijk robuuste en performante applicaties te bouwen. Deze gids behandelt de essentiële patronen om Doctrine-relaties effectief te beheren. > **Kernprincipe** > > Doctrine gebruikt het concept van eigenaarszijde en inverse zijde. Het begrijpen van dit onderscheid voorkomt veel bugs gerelateerd aan relatie-persistentie. ## Doctrine-relatietypen Doctrine biedt vier relatietypen tussen entiteiten: OneToOne, OneToMany, ManyToOne en ManyToMany. Elk type beantwoordt aan een specifieke bedrijfsbehoefte en heeft zijn eigen performance-eigenschappen. ### ManyToOne-relatie: het meest voorkomende geval De ManyToOne-relatie vertegenwoordigt de meest voorkomende databasekoppeling. Meerdere entiteiten aan de ene kant zijn gerelateerd aan één entiteit aan de andere kant. Deze relatie is altijd de eigenaarszijde van de associatie. ```php // src/Entity/Comment.php // A comment belongs to a single 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: many comments → one article // This entity owns the relationship (foreign key stored here) #[ORM\ManyToOne(targetEntity: Article::class, inversedBy: 'comments')] #[ORM\JoinColumn(nullable: false)] // Relationship is required 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; } } ``` De `Comment`-entiteit bevat de foreign key `article_id`. Doctrine zorgt automatisch voor de persistentie: het wijzigen van `$comment->setArticle($article)` creëert de koppeling in de database. ### OneToMany-relatie: de inverse zijde De OneToMany-relatie vertegenwoordigt de inverse zijde van een ManyToOne. Het maakt navigatie van de «één»-entiteit naar de «veel»-entiteiten mogelijk. Deze relatie is nooit de eigenaarszijde en bevat geen foreign key. ```php // src/Entity/Article.php // An article has multiple comments 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: one article → many comments // Inverse side: mappedBy points to the owning entity's property #[ORM\OneToMany( targetEntity: Comment::class, mappedBy: 'article', cascade: ['persist', 'remove'], // Cascade operations orphanRemoval: true // Remove orphaned comments )] private Collection $comments; public function __construct() { // Initialize collection in the constructor $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); // CRITICAL: synchronize the owning side $comment->setArticle($this); } return $this; } public function removeComment(Comment $comment): self { if ($this->comments->removeElement($comment)) { // orphanRemoval handles deletion } return $this; } } ``` De methoden `addComment()` en `removeComment()` waarborgen de bidirectionele consistentie. Zonder de regel `$comment->setArticle($this)` zou de relatie niet correct worden gepersisteerd. > **Veelvoorkomende valkuil** > > Vergeten beide zijden van een bidirectionele relatie te synchroniseren is de meest voorkomende fout. Wijzig altijd de eigenaarszijde om persistentie te garanderen. ## ManyToMany-relatie: meervoudige associaties De ManyToMany-relatie verbindt meerdere entiteiten aan beide zijden. Doctrine creëert automatisch een join-tabel. Eén zijde moet als eigenaar worden aangewezen via het `inversedBy`-attribuut. ```php // src/Entity/Article.php // An article can have multiple 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 { // ... other properties // ManyToMany: owning side (inversedBy) // Join table: article_tag (auto-generated) #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')] #[ORM\JoinTable(name: 'article_tag')] // Explicit table name 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); // Owning side: no need to sync other side for persistence // but recommended for in-memory consistency $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 // A tag can belong to multiple 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; // Inverse side: mappedBy points to the owner #[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; } } ``` Voor een ManyToMany-relatie met aanvullende gegevens (associatiedatum, volgorde, enz.) is het beter deze om te zetten naar twee ManyToOne-relaties die naar een tussenliggende entiteit wijzen. ## Laadstrategieën en performance Het laden van relaties is het kritische performance-punt bij Doctrine. Er bestaan drie strategieën: LAZY (standaard), EAGER en EXTRA_LAZY. ### Lazy Loading: laden op aanvraag Lazy loading haalt relaties pas op bij de eerste toegang. Deze standaardstrategie voorkomt onnodige queries, maar kan het N+1-probleem veroorzaken. ```php // src/Repository/ArticleRepository.php // Demonstrating the N+1 problem 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); } // N+1 PROBLEM: one query per article for comments public function findAllWithLazyComments(): array { // Query 1: SELECT * FROM article $articles = $this->findAll(); // In view or service: // foreach ($articles as $article) { // $article->getComments(); // Query N: SELECT * FROM comment WHERE article_id = ? // } return $articles; } } ``` Voor 100 artikelen genereert deze code 101 SQL-queries: één voor de artikelen en daarna één per artikel om de reacties te laden. ### Eager loading met joins Eager loading lost het N+1-probleem op door relaties in dezelfde query op te halen via joins. ```php // src/Repository/ArticleRepository.php // Optimized loading with joins 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: explicit join with fetch join public function findAllWithComments(): array { return $this->createQueryBuilder('a') // LEFT JOIN loads articles even without comments ->leftJoin('a.comments', 'c') // addSelect includes comments in the result ->addSelect('c') // Sort by comment creation date ->orderBy('c.createdAt', 'DESC') ->getQuery() ->getResult(); // Single SQL query with JOIN } // Loading multiple relationships public function findAllWithCommentsAndTags(): array { return $this->createQueryBuilder('a') ->leftJoin('a.comments', 'c') ->addSelect('c') ->leftJoin('a.tags', 't') ->addSelect('t') ->leftJoin('c.author', 'ca') // Comment author join ->addSelect('ca') ->getQuery() ->getResult(); } } ``` Het gebruik van `addSelect()` na elke `leftJoin()` is essentieel. Zonder `addSelect()` voert Doctrine de join uit, maar laadt het de gerelateerde entiteiten niet. ### Extra Lazy: grote collecties optimaliseren De EXTRA_LAZY-strategie optimaliseert bewerkingen op grote collecties zonder alle elementen te laden. ```php // src/Entity/Category.php // Category with potentially thousands of products 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: optimizes count(), contains(), slice() #[ORM\OneToMany( targetEntity: Product::class, mappedBy: 'category', fetch: 'EXTRA_LAZY' // Does not load all products )] private Collection $products; public function __construct() { $this->products = new ArrayCollection(); } // count() executes SELECT COUNT(*) instead of loading collection public function getProductCount(): int { return $this->products->count(); // SQL: SELECT COUNT(*) FROM product WHERE category_id = ? } // contains() checks existence without loading everything public function hasProduct(Product $product): bool { return $this->products->contains($product); // SQL: SELECT 1 FROM product WHERE id = ? AND category_id = ? } // slice() loads only a 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 voorkomt het laden van duizenden entiteiten voor een eenvoudige controle of telling. > **Wanneer EXTRA_LAZY gebruiken** > > Pas EXTRA_LAZY toe op potentieel grote collecties (meer dan 100 elementen) waarbij operaties zoals count(), contains() of slice() vaak voorkomen. ## Cascade en lifecycle-beheer Cascade-opties automatiseren de propagatie van bewerkingen naar gerelateerde entiteiten. Drie hoofdopties: persist, remove en orphanRemoval. ### Cascade Persist: automatische persistentie Cascade persist slaat nieuwe gerelateerde entiteiten automatisch op tijdens flush. ```php // src/Entity/Order.php // Order with cascaded order lines 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 is a SQL reserved word class Order { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 50)] private string $reference; // cascade persist: OrderLines are persisted with Order #[ORM\OneToMany( targetEntity: OrderLine::class, mappedBy: 'order', cascade: ['persist'] // Automatically persist lines )] 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 // Order creation with 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 synchronizes the relationship $order->addLine($line); } // Only Order is explicitly persisted // OrderLines are persisted automatically (cascade) $this->em->persist($order); $this->em->flush(); return $order; } } ``` ### Cascade Remove en OrphanRemoval Cascade remove verwijdert gerelateerde entiteiten. OrphanRemoval gaat verder door entiteiten te verwijderen die los van de collectie zijn geraakt. ```php // src/Entity/BlogPost.php // Blog post with 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'], // Delete images with post orphanRemoval: true // Also delete detached images )] private Collection $images; public function __construct() { $this->images = new ArrayCollection(); } public function removeImage(Image $image): self { if ($this->images->removeElement($image)) { // orphanRemoval: image will be deleted on flush // Without orphanRemoval: image would remain in DB without blogPost } return $this; } public function clearImages(): self { // Clear collection → all images will be deleted $this->images->clear(); return $this; } } ``` Het belangrijkste verschil: `cascade: ['remove']` verwijdert gerelateerde entiteiten alleen wanneer de ouder-entiteit wordt verwijderd. `orphanRemoval: true` verwijdert ook entiteiten die uit de collectie zijn gehaald. ## Geavanceerde DQL-queries voor relaties DQL (Doctrine Query Language) biedt maximale flexibiliteit voor queries met complexe relaties. ### Filteren op relaties ```php // src/Repository/ArticleRepository.php // Advanced queries on relationships 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 with at least one comment public function findWithComments(): array { return $this->createQueryBuilder('a') ->innerJoin('a.comments', 'c') // INNER JOIN excludes articles without comments ->addSelect('c') ->groupBy('a.id') ->getQuery() ->getResult(); } // Articles by tag with comment count 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(); } // Recent articles with comment authors public function findRecentWithAuthors(\DateTimeInterface $since): array { return $this->createQueryBuilder('a') ->leftJoin('a.comments', 'c') ->addSelect('c') ->leftJoin('c.author', 'u') // Join on comment author ->addSelect('u') ->where('a.publishedAt > :since') ->setParameter('since', $since) ->orderBy('a.publishedAt', 'DESC') ->getQuery() ->getResult(); } } ``` ### Subqueries en aggregaties ```php // src/Repository/UserRepository.php // Complex queries with subqueries 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); } // Most active users (by article count) 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 to filter after GROUP BY ->orderBy('articleCount', 'DESC') ->setMaxResults($limit) ->getQuery() ->getResult(); } // Users with articles having more than 10 comments public function findAuthorsWithPopularArticles(): array { // DQL subquery $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(); } } ``` ## Best practices en geavanceerde patronen ### Correcte initialisatie van collecties Collecties moeten altijd in de constructor worden geïnitialiseerd om typefouten te voorkomen. ```php // src/Entity/Author.php // Proper relationship initialization 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; // ALWAYS initialize in constructor public function __construct() { $this->books = new ArrayCollection(); $this->favoriteGenres = new ArrayCollection(); } // Utility method to check if collection is loaded public function areBooksLoaded(): bool { return $this->books->isInitialized(); } } ``` ### Circulaire referenties vermijden Bidirectionele relaties kunnen oneindige loops veroorzaken tijdens serialisatie. ```php // src/Entity/Department.php // Handling circular references 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 limits serialization depth #[ORM\OneToMany(targetEntity: Employee::class, mappedBy: 'department')] #[Groups(['department:read'])] #[MaxDepth(1)] // Does not serialize employee relations private Collection $employees; public function __construct() { $this->employees = new ArrayCollection(); } } ``` ### Repository-patroon met dynamische criteria ```php // src/Repository/ProductRepository.php // Flexible search criteria 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); } // Search with optional filters public function findByCriteria(array $criteria): array { $qb = $this->createQueryBuilder('p') ->leftJoin('p.category', 'c') ->addSelect('c') ->leftJoin('p.tags', 't') ->addSelect('t'); // Conditional filters 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(); } } ``` ## Conclusie Het beheersen van Doctrine ORM-relaties berust op enkele fundamentele principes: ✅ **Eigenaarszijde vs inverse zijde**: wijzig altijd de eigenaarszijde om persistentie te garanderen ✅ **Bidirectionele synchronisatie**: add/remove-methoden moeten beide zijden synchroniseren ✅ **Fetch joins**: gebruik `addSelect()` na elke join om het N+1-probleem te voorkomen ✅ **EXTRA_LAZY**: activeer dit op grote collecties om count() en contains() te optimaliseren ✅ **Cascade met zorg**: persist is vaak nuttig; remove en orphanRemoval hangen af van de business-context ✅ **Initialisatie van collecties**: altijd in de constructor met ArrayCollection Deze patronen vormen de basis van een performante Symfony-applicatie. De volgende stap betreft het beheersen van indexen en native queries voor gevallen met extreme performance-eisen. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/symfony/doctrine-orm-mastering-relationships