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.

Relaciones Doctrine ORM en Symfony - Guía completa

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.

Principio fundamental

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.

src/Entity/Comment.phpphp
// 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.

src/Entity/Article.phpphp
// 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.

Trampa frecuente

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.

src/Entity/Article.phpphp
// 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;
    }
}
src/Entity/Tag.phpphp
// 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.

src/Repository/ArticleRepository.phpphp
// 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.

src/Repository/ArticleRepository.phpphp
// 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.

src/Entity/Category.phpphp
// 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.

Cuándo utilizar EXTRA_LAZY

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.

src/Entity/Order.phpphp
// 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;
    }
}
src/Service/OrderService.phpphp
// 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.

src/Entity/BlogPost.phpphp
// 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

src/Repository/ArticleRepository.phpphp
// 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

src/Repository/UserRepository.phpphp
// 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.

src/Entity/Author.phpphp
// 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.

src/Entity/Department.phpphp
// 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

src/Repository/ProductRepository.phpphp
// 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

#doctrine
#symfony
#orm
#php
#database

Compartir

Artículos relacionados