# Doctrine ORM: Dominando relacionamentos no Symfony > Guia completo dos relacionamentos Doctrine ORM no Symfony. OneToMany, ManyToMany, estratégias de carregamento e otimização de performance com exemplos práticos. - Published: 2026-02-17 - Updated: 2026-04-28 - Author: SharpSkill - Tags: doctrine, symfony, orm, php, database - Reading time: 18 min --- Os relacionamentos entre entidades formam a espinha dorsal de qualquer aplicação Symfony que utiliza o Doctrine ORM. Compreender os diferentes tipos de relacionamentos, as estratégias de carregamento e as armadilhas de performance permite construir aplicações robustas e eficientes. Este guia aborda os padrões essenciais para gerenciar os relacionamentos Doctrine de forma eficaz. > **Princípio fundamental** > > O Doctrine utiliza o conceito de lado proprietário e lado inverso. Compreender essa distinção evita muitos bugs relacionados à persistência dos relacionamentos. ## Tipos de relacionamentos no Doctrine O Doctrine oferece quatro tipos de relacionamentos entre entidades: OneToOne, OneToMany, ManyToOne e ManyToMany. Cada tipo atende a uma necessidade de negócio específica e possui suas próprias características de performance. ### Relacionamento ManyToOne: o caso mais comum O relacionamento ManyToOne representa o vínculo de banco de dados mais frequente. Várias entidades de um lado se relacionam com uma única entidade do outro lado. Esse relacionamento é sempre o lado proprietário da associação. ```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; } } ``` A entidade `Comment` contém a chave estrangeira `article_id`. O Doctrine cuida da persistência automaticamente: modificar `$comment->setArticle($article)` cria o vínculo no banco de dados. ### Relacionamento OneToMany: o lado inverso O relacionamento OneToMany representa o lado inverso de um ManyToOne. Permite navegar da entidade «um» para as entidades «muitos». Esse relacionamento nunca é o lado proprietário e não contém chave estrangeira. ```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; } } ``` Os métodos `addComment()` e `removeComment()` garantem a consistência bidirecional. Sem a linha `$comment->setArticle($this)`, o relacionamento não seria persistido corretamente. > **Armadilha comum** > > Esquecer de sincronizar os dois lados de um relacionamento bidirecional é o erro mais comum. Convém modificar sempre o lado proprietário para garantir a persistência. ## Relacionamento ManyToMany: associações múltiplas O relacionamento ManyToMany conecta várias entidades em ambos os lados. O Doctrine cria automaticamente uma tabela de junção. Um lado deve ser designado como proprietário pelo atributo `inversedBy`. ```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; } } ``` Para um relacionamento ManyToMany com dados adicionais (data de associação, ordem etc.), convém transformá-lo em dois relacionamentos ManyToOne apontando para uma entidade intermediária. ## Estratégias de carregamento e performance O carregamento dos relacionamentos é o ponto crítico de performance no Doctrine. Existem três estratégias: LAZY (padrão), EAGER e EXTRA_LAZY. ### Lazy Loading: carregamento sob demanda O carregamento preguiçoso busca os relacionamentos somente no primeiro acesso. Essa estratégia padrão evita consultas desnecessárias, mas pode gerar o problema N+1. ```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; } } ``` Para 100 artigos, esse código gera 101 consultas SQL: uma para os artigos e, em seguida, uma por artigo para carregar os comentários. ### Carregamento ansioso com joins O carregamento ansioso resolve o problema N+1 ao recuperar os relacionamentos na mesma consulta usando 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(); } } ``` Utilizar `addSelect()` após cada `leftJoin()` é essencial. Sem `addSelect()`, o Doctrine executa o join, mas não carrega as entidades relacionadas. ### Extra Lazy: otimizando coleções grandes A estratégia EXTRA_LAZY otimiza operações em coleções grandes sem carregar todos os elementos. ```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 } } ``` O EXTRA_LAZY evita carregar milhares de entidades para uma simples verificação ou contagem. > **Quando usar EXTRA_LAZY** > > Convém aplicar EXTRA_LAZY a coleções potencialmente grandes (mais de 100 elementos) onde operações count(), contains() ou slice() são frequentes. ## Cascade e gestão do ciclo de vida As opções de cascade automatizam a propagação de operações para entidades relacionadas. Três opções principais: persist, remove e orphanRemoval. ### Cascade Persist: persistência automática O cascade persist salva automaticamente novas entidades relacionadas durante o 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 e OrphanRemoval O cascade remove exclui as entidades relacionadas. O OrphanRemoval vai além ao excluir as entidades desvinculadas da coleção. ```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; } } ``` A diferença chave: `cascade: ['remove']` apaga entidades relacionadas apenas quando a entidade pai é excluída. `orphanRemoval: true` também apaga as entidades removidas da coleção. ## Consultas DQL avançadas para relacionamentos O DQL (Doctrine Query Language) oferece máxima flexibilidade para consultas envolvendo relacionamentos complexos. ### Filtragem em relacionamentos ```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(); } } ``` ### Subconsultas e agregações ```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(); } } ``` ## Boas práticas e padrões avançados ### Inicialização correta das coleções As coleções devem sempre ser inicializadas no construtor para evitar erros de tipo. ```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(); } } ``` ### Evitando referências circulares Relacionamentos bidirecionais podem criar loops infinitos durante a serialização. ```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(); } } ``` ### Padrão Repository com critérios dinâmicos ```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(); } } ``` ## Conclusão Dominar os relacionamentos Doctrine ORM se baseia em alguns princípios fundamentais: ✅ **Lado proprietário vs inverso**: sempre modificar o lado proprietário para garantir a persistência ✅ **Sincronização bidirecional**: os métodos add/remove devem sincronizar ambos os lados ✅ **Fetch joins**: usar `addSelect()` após cada join para evitar o problema N+1 ✅ **EXTRA_LAZY**: ativar em coleções grandes para otimizar count() e contains() ✅ **Cascade com cuidado**: persist costuma ser útil; remove e orphanRemoval dependem do contexto de negócio ✅ **Inicialização das coleções**: sempre no construtor com ArrayCollection Esses padrões formam a base de uma aplicação Symfony eficiente. O próximo passo envolve dominar os índices e as consultas nativas para casos extremos de performance. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/pt/blog/symfony/doctrine-orm-mastering-relationships