Doctrine ORM: Dominar las relaciones en Symfony
Guía completa de las relaciones Doctrine ORM en Symfony. OneToMany, ManyToMany, estrategias de carga y optimización del rendimiento con ejemplos prácticos.

Las relaciones entre entidades son la columna vertebral de toda aplicación Symfony que utiliza Doctrine ORM. Comprender los distintos tipos de relaciones, las estrategias de carga y los problemas de rendimiento permite construir aplicaciones robustas y eficientes. Esta guía cubre los patrones esenciales para gestionar las relaciones Doctrine de forma efectiva.
Doctrine utiliza el concepto de lado propietario y lado inverso. Comprender esta distinción evita muchos errores relacionados con la persistencia de las relaciones.
Tipos de relaciones en Doctrine
Doctrine ofrece cuatro tipos de relaciones entre entidades: OneToOne, OneToMany, ManyToOne y ManyToMany. Cada tipo responde a una necesidad de negocio específica y presenta sus propias características de rendimiento.
Relación ManyToOne: el caso más frecuente
La relación ManyToOne representa el vínculo más común en una base de datos. Varias entidades de un lado se relacionan con una sola entidad del otro lado. Esta relación constituye siempre el lado propietario de la asociación.
// 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;
}
}La entidad Comment contiene la clave foránea article_id. Doctrine se encarga de la persistencia de forma automática: modificar $comment->setArticle($article) crea el vínculo en la base de datos.
Relación OneToMany: el lado inverso
La relación OneToMany representa el lado inverso de un ManyToOne. Permite navegar desde la entidad «uno» hacia las entidades «muchas». Esta relación nunca constituye el lado propietario y no contiene clave foránea.
// 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<int, Comment>
*/
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;
}
}Los métodos addComment() y removeComment() aseguran la coherencia bidireccional. Sin la línea $comment->setArticle($this), la relación no se persistiría correctamente.
Olvidar sincronizar ambos lados de una relación bidireccional es el error más común. Conviene modificar siempre el lado propietario para garantizar la persistencia.
Relación ManyToMany: asociaciones múltiples
La relación ManyToMany conecta varias entidades en ambos lados. Doctrine crea automáticamente una tabla de unión. Es necesario designar un lado como propietario mediante el atributo inversedBy.
// 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<int, Tag>
*/
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;
}
}// 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<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;
}
}Para una relación ManyToMany con datos adicionales (fecha de asociación, orden, etc.), conviene transformarla en dos relaciones ManyToOne apuntando a una entidad intermedia.
¿Listo para aprobar tus entrevistas de Symfony?
Practica con nuestros simuladores interactivos, flashcards y tests técnicos.
Estrategias de carga y rendimiento
La carga de las relaciones es el punto crítico de rendimiento en Doctrine. Existen tres estrategias: LAZY (por defecto), EAGER y EXTRA_LAZY.
Lazy Loading: carga bajo demanda
La carga perezosa solo recupera las relaciones cuando se accede a ellas por primera vez. Esta estrategia por defecto evita consultas innecesarias, pero puede provocar el problema del N+1.
// 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 artículos, este código genera 101 consultas SQL: una para los artículos y luego una por cada artículo para cargar los comentarios.
Carga ansiosa con joins
La carga ansiosa resuelve el problema N+1 al recuperar las relaciones en la misma consulta mediante joins.
// 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() después de cada leftJoin() resulta esencial. Sin addSelect(), Doctrine ejecuta el join pero no carga las entidades relacionadas.
Extra Lazy: optimizar las colecciones grandes
La estrategia EXTRA_LAZY optimiza las operaciones sobre colecciones grandes sin cargar todos los elementos.
// 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 evita cargar miles de entidades para una simple verificación o un conteo.
Conviene aplicar EXTRA_LAZY a las colecciones potencialmente grandes (más de 100 elementos) en las que las operaciones count(), contains() o slice() son frecuentes.
Cascada y gestión del ciclo de vida
Las opciones de cascada automatizan la propagación de las operaciones a las entidades relacionadas. Tres opciones principales: persist, remove y orphanRemoval.
Cascade Persist: persistencia automática
La cascada persist guarda automáticamente las nuevas entidades relacionadas durante el flush.
// 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;
}
}// 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 y OrphanRemoval
La cascada remove elimina las entidades relacionadas. OrphanRemoval va más allá al eliminar también las entidades desvinculadas de la colección.
// 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;
}
}La diferencia clave: cascade: ['remove'] solo elimina las entidades relacionadas cuando se elimina la entidad padre. orphanRemoval: true también elimina las entidades retiradas de la colección.
Consultas DQL avanzadas para las relaciones
DQL (Doctrine Query Language) ofrece la máxima flexibilidad para consultas que involucran relaciones complejas.
Filtrado sobre relaciones
// 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 y agregaciones
// 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();
}
}¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
Buenas prácticas y patrones avanzados
Inicialización correcta de las colecciones
Las colecciones deben inicializarse siempre en el constructor para evitar errores de tipo.
// 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();
}
}Evitar las referencias circulares
Las relaciones bidireccionales pueden generar bucles infinitos durante la serialización.
// 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();
}
}Patrón Repository con criterios dinámicos
// 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();
}
}Conclusión
Dominar las relaciones Doctrine ORM se basa en algunos principios fundamentales:
✅ Lado propietario vs inverso: modificar siempre el lado propietario para garantizar la persistencia
✅ Sincronización bidireccional: los métodos add/remove deben sincronizar ambos lados
✅ Fetch joins: usar addSelect() después de cada join para evitar el problema N+1
✅ EXTRA_LAZY: activarlo en colecciones grandes para optimizar count() y contains()
✅ Cascada con cuidado: persist suele ser útil, remove y orphanRemoval dependen del contexto de negocio
✅ Inicialización de colecciones: siempre en el constructor con ArrayCollection
Estos patrones forman la base de una aplicación Symfony eficiente. El siguiente paso consiste en dominar los índices y las consultas nativas para los casos de rendimiento extremo.
¡Empieza a practicar!
Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.
Etiquetas
Compartir
Artículos relacionados

Preguntas de entrevista Symfony: Top 25 en 2026
Las 25 preguntas de entrevista Symfony más frecuentes. Arquitectura, Doctrine ORM, servicios, seguridad, formularios y tests con respuestas detalladas y ejemplos de código.

Symfony 7: API Platform y Mejores Practicas
Guia completa de API Platform 4 con Symfony 7. Recursos, grupos de serializacion, state processors, filtros, seguridad y pruebas automatizadas para APIs REST profesionales.

Symfony Live Components y UX 3.0: Aplicaciones Reactivas Sin JavaScript en 2026
Descubre cómo Symfony Live Components revoluciona el desarrollo web con interactividad en tiempo real sin escribir JavaScript. Guía completa con ejemplos prácticos de UX 3.0.