# Doctrine ORM: Beziehungen in Symfony meistern > Vollständiger Leitfaden zu Doctrine-ORM-Beziehungen in Symfony. OneToMany, ManyToMany, Ladestrategien und Performance-Optimierung mit praktischen Beispielen. - Published: 2026-02-17 - Updated: 2026-04-28 - Author: SharpSkill - Tags: doctrine, symfony, orm, php, database - Reading time: 18 min --- Beziehungen zwischen Entitäten bilden das Rückgrat jeder Symfony-Anwendung, die Doctrine ORM verwendet. Ein solides Verständnis der verschiedenen Beziehungstypen, Ladestrategien und Performance-Fallstricke ermöglicht den Aufbau robuster und leistungsfähiger Anwendungen. Dieser Leitfaden behandelt die wesentlichen Muster zur effektiven Verwaltung von Doctrine-Beziehungen. > **Grundprinzip** > > Doctrine verwendet das Konzept der besitzenden und der inversen Seite. Das Verständnis dieser Unterscheidung verhindert viele Fehler bei der Persistenz von Beziehungen. ## Doctrine-Beziehungstypen Doctrine bietet vier Beziehungstypen zwischen Entitäten: OneToOne, OneToMany, ManyToOne und ManyToMany. Jeder Typ deckt eine bestimmte fachliche Anforderung ab und bringt eigene Performance-Eigenschaften mit. ### ManyToOne-Beziehung: der häufigste Fall Die ManyToOne-Beziehung stellt die am häufigsten vorkommende Datenbankverknüpfung dar. Mehrere Entitäten auf einer Seite stehen mit einer einzelnen Entität auf der anderen Seite in Beziehung. Diese Beziehung ist immer die besitzende Seite der Assoziation. ```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; } } ``` Die Entität `Comment` hält den Fremdschlüssel `article_id`. Doctrine kümmert sich automatisch um die Persistenz: Der Aufruf `$comment->setArticle($article)` erzeugt die Verknüpfung in der Datenbank. ### OneToMany-Beziehung: die inverse Seite Die OneToMany-Beziehung repräsentiert die inverse Seite einer ManyToOne-Beziehung. Sie erlaubt die Navigation von der Eins-Entität zu den Viele-Entitäten. Diese Beziehung ist niemals die besitzende Seite und enthält keinen Fremdschlüssel. ```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; } } ``` Die Methoden `addComment()` und `removeComment()` gewährleisten die bidirektionale Konsistenz. Ohne die Zeile `$comment->setArticle($this)` würde die Beziehung nicht korrekt persistiert. > **Häufige Falle** > > Das Vergessen der Synchronisation beider Seiten einer bidirektionalen Beziehung ist der häufigste Fehler. Stets die besitzende Seite ändern, um die Persistenz zu garantieren. ## ManyToMany-Beziehung: mehrfache Assoziationen Die ManyToMany-Beziehung verbindet mehrere Entitäten auf beiden Seiten. Doctrine erstellt automatisch eine Verknüpfungstabelle. Eine Seite muss über das Attribut `inversedBy` als besitzend gekennzeichnet werden. ```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; } } ``` Für eine ManyToMany-Beziehung mit zusätzlichen Daten (Assoziationsdatum, Reihenfolge usw.) empfiehlt es sich, sie in zwei ManyToOne-Beziehungen umzuwandeln, die auf eine Zwischenentität zeigen. ## Ladestrategien und Performance Das Laden von Beziehungen ist der kritische Performance-Punkt bei Doctrine. Drei Strategien existieren: LAZY (Standard), EAGER und EXTRA_LAZY. ### Lazy Loading: Bedarfsgesteuertes Laden Lazy Loading ruft Beziehungen erst beim ersten Zugriff ab. Diese Standardstrategie vermeidet unnötige Abfragen, kann aber das N+1-Problem erzeugen. ```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; } } ``` Bei 100 Artikeln erzeugt dieser Code 101 SQL-Abfragen: eine für die Artikel und anschließend eine pro Artikel zum Laden der Kommentare. ### Eager Loading mit Joins Eager Loading löst das N+1-Problem, indem die Beziehungen über Joins in derselben Abfrage geladen werden. ```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(); } } ``` Die Verwendung von `addSelect()` nach jedem `leftJoin()` ist entscheidend. Ohne `addSelect()` führt Doctrine den Join aus, lädt aber die zugehörigen Entitäten nicht. ### Extra Lazy: Optimierung großer Sammlungen Die Strategie EXTRA_LAZY optimiert Operationen auf großen Sammlungen, ohne alle Elemente zu 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 vermeidet das Laden Tausender Entitäten für eine einfache Prüfung oder Zählung. > **Wann EXTRA_LAZY verwenden** > > EXTRA_LAZY auf potenziell große Sammlungen (> 100 Elemente) anwenden, in denen Operationen wie count(), contains() oder slice() häufig vorkommen. ## Cascade und Lebenszyklus-Management Cascade-Optionen automatisieren die Weitergabe von Operationen an verwandte Entitäten. Drei Hauptoptionen: persist, remove und orphanRemoval. ### Cascade Persist: Automatische Persistenz Cascade persist speichert neue verwandte Entitäten beim Flush automatisch. ```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 und OrphanRemoval Cascade remove löscht verwandte Entitäten. OrphanRemoval geht weiter und löscht Entitäten, die aus der Sammlung entfernt wurden. ```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; } } ``` Der entscheidende Unterschied: `cascade: ['remove']` löscht verwandte Entitäten nur, wenn die Elternentität gelöscht wird. `orphanRemoval: true` löscht zusätzlich Entitäten, die aus der Sammlung entfernt wurden. ## Erweiterte DQL-Abfragen für Beziehungen DQL (Doctrine Query Language) bietet maximale Flexibilität für Abfragen mit komplexen Beziehungen. ### Filtern auf Beziehungen ```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(); } } ``` ### Unterabfragen und Aggregationen ```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 und fortgeschrittene Muster ### Korrekte Initialisierung von Sammlungen Sammlungen sollten stets im Konstruktor initialisiert werden, um Typfehler zu vermeiden. ```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(); } } ``` ### Zirkuläre Referenzen vermeiden Bidirektionale Beziehungen können während der Serialisierung Endlosschleifen erzeugen. ```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-Muster mit dynamischen Kriterien ```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(); } } ``` ## Fazit Die Beherrschung der Doctrine-ORM-Beziehungen beruht auf einigen grundlegenden Prinzipien: ✅ **Besitzende vs. inverse Seite**: stets die besitzende Seite ändern, um die Persistenz zu garantieren ✅ **Bidirektionale Synchronisation**: add/remove-Methoden müssen beide Seiten synchronisieren ✅ **Fetch Joins**: nach jedem Join `addSelect()` verwenden, um das N+1-Problem zu vermeiden ✅ **EXTRA_LAZY**: bei großen Sammlungen aktivieren, um count() und contains() zu optimieren ✅ **Cascade mit Bedacht**: persist ist häufig nützlich; remove und orphanRemoval hängen vom fachlichen Kontext ab ✅ **Sammlungsinitialisierung**: stets im Konstruktor mit ArrayCollection Diese Muster bilden das Fundament einer leistungsfähigen Symfony-Anwendung. Der nächste Schritt besteht darin, Indizes und native Abfragen für extreme Performance-Anforderungen zu beherrschen. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/de/blog/symfony/doctrine-orm-mastering-relationships