Módulo 06: Doctrine: relaciones y consultas

Objetivos del módulo

  • Comprender los tipos de relaciones entre entidades: ManyToOne, OneToMany, ManyToMany y OneToOne
  • Distinguir el lado propietario del lado inverso y usar correctamente mappedBy e inversedBy
  • Trabajar con colecciones (Collection, ArrayCollection) y los métodos add/remove que genera make:entity
  • Controlar el borrado en cascada con cascade, orphanRemoval y onDelete
  • Entender la carga lazy frente a eager y resolver el problema N+1
  • Escribir repositorios personalizados con QueryBuilder, DQL y SQL nativo
  • Paginar resultados con el Paginator de Doctrine
  • Generar datos de prueba con DoctrineFixturesBundle y Foundry

1. ¿Por qué necesitamos relaciones?

En el módulo anterior creaste la entidad Article con sus campos title, slug, content, createdAt y publishedAt. Pero un blog real no vive de artículos aislados: cada artículo pertenece a una categoría, recibe comentarios, tiene etiquetas y lo escribe un usuario.

📘 Concepto: Una relación (o asociación) conecta dos entidades. En la base de datos se traduce en claves foráneas y, a veces, en tablas intermedias. En PHP se traduce en propiedades que contienen objetos (o colecciones de objetos) en lugar de identificadores.

Piensa en una biblioteca: cada libro está en una estantería, pero una estantería contiene muchos libros. No guardas en el libro “estantería número 7” y luego buscas a mano; simplemente sigues la flecha. Doctrine te permite escribir $article->getCategory()->getName() y él se encarga de las consultas SQL.

Tipos de relaciones

Relación Ejemplo en el blog En la base de datos
ManyToOne Muchos artículos → una categoría Columna category_id en article
OneToMany Una categoría → muchos artículos (Es el lado inverso de ManyToOne, no crea columna)
ManyToMany Muchos artículos ↔ muchas etiquetas Tabla intermedia article_tag
OneToOne Un usuario → un perfil Columna profile_id única en user

Diagrama entidad-relación del blog

Este es el modelo completo que construiremos a lo largo del curso (la entidad User se crea en el módulo 10):

erDiagram
    CATEGORY ||--o{ ARTICLE : "agrupa"
    USER ||--o{ ARTICLE : "escribe"
    ARTICLE ||--o{ COMMENT : "recibe"
    ARTICLE }o--o{ TAG : "se etiqueta con"
    USER ||--o| USER_PROFILE : "tiene"

    CATEGORY {
        int id PK
        string name
        string slug
    }
    ARTICLE {
        int id PK
        string title
        string slug
        text content
        datetime createdAt
        datetime publishedAt
        int category_id FK
        int author_id FK
    }
    COMMENT {
        int id PK
        string authorName
        text content
        datetime createdAt
        int article_id FK
    }
    TAG {
        int id PK
        string name
    }
    USER {
        int id PK
        string email
        json roles
        string password
    }
    USER_PROFILE {
        int id PK
        string bio
        string website
    }

💡 Consejo: Antes de escribir una sola línea de código, dibuja tu modelo (en papel o con Mermaid). Decidir mal una relación al principio es mucho más caro que pensar diez minutos.


2. ManyToOne y OneToMany: Article y Category

2.1 Crear la entidad Category

Primero creamos Category con make:entity:

php bin/console make:entity Category

Añade los campos name (string, 100) y slug (string, 120, único). Después, edita Article para añadir la relación. El asistente de MakerBundle te guía:

$ php bin/console make:entity Article

 Your entity already exists! So let's add some new fields!

 New property name (press <return> to stop adding fields):
 > category

 Field type (enter ? to see all types) [string]:
 > relation

 What class should this entity be related to?:
 > Category

 What type of relationship is this?
 ------------ ---------------------------------------------------------------------
  Type         Description
 ------------ ---------------------------------------------------------------------
  ManyToOne    Each Article relates to (has) one Category.
               Each Category can relate to (can have) many Article objects.
  OneToMany    Each Article can relate to (can have) many Category objects.
               Each Category relates to (has) one Article.
  ManyToMany   Each Article can relate to (can have) many Category objects.
               Each Category can also relate to (can also have) many Article objects.
  OneToOne     Each Article relates to (has) exactly one Category.
               Each Category also relates to (has) exactly one Article.
 ------------ ---------------------------------------------------------------------

 Relation type? [ManyToOne, OneToMany, ManyToMany, OneToOne]:
 > ManyToOne

 Is the Article.category property allowed to be null (nullable)? (yes/no) [yes]:
 > no

 Do you want to add a new property to Category so that you can access/update
 Article objects from it - e.g. $category->getArticles()? (yes/no) [yes]:
 > yes

 New field name inside Category [articles]:
 > articles

 Do you want to automatically delete orphaned App\Entity\Article objects (orphanRemoval)? (yes/no) [no]:
 > no

💡 Consejo: Si dudas del tipo, escribe relation y lee la tabla que te muestra el asistente: describe la relación en lenguaje natural con tus propias clases.

2.2 El código generado en Article (lado ManyToOne)

<?php
// src/Entity/Article.php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\ArticleRepository;
use Doctrine\DBAL\Types\Types;
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 = null;

    #[ORM\Column(length: 255, unique: true)]
    private ?string $slug = null;

    #[ORM\Column(type: Types::TEXT)]
    private ?string $content = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $publishedAt = null;

    // Lado propietario: aquí vive la clave foránea category_id
    #[ORM\ManyToOne(inversedBy: 'articles')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Category $category = null;

    // ... getters y setters de los campos anteriores

    public function getCategory(): ?Category
    {
        return $this->category;
    }

    public function setCategory(?Category $category): static
    {
        $this->category = $category;

        return $this;
    }
}
Atributo Significado
#[ORM\ManyToOne] Muchos Article apuntan a un Category. El tipo destino se deduce del tipo de la propiedad
inversedBy: 'articles' Nombre de la propiedad del otro lado (Category::$articles)
#[ORM\JoinColumn(nullable: false)] La columna category_id es NOT NULL: todo artículo debe tener categoría

2.3 El código generado en Category (lado OneToMany)

<?php
// src/Entity/Category.php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\CategoryRepository;
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 = null;

    #[ORM\Column(length: 120, unique: true)]
    private ?string $slug = null;

    /**
     * @var Collection<int, Article>
     */
    #[ORM\OneToMany(targetEntity: Article::class, mappedBy: 'category')]
    private Collection $articles;

    public function __construct()
    {
        // Una colección vacía, nunca null
        $this->articles = new ArrayCollection();
    }

    /**
     * @return Collection<int, Article>
     */
    public function getArticles(): Collection
    {
        return $this->articles;
    }

    public function addArticle(Article $article): static
    {
        if (!$this->articles->contains($article)) {
            $this->articles->add($article);
            // Sincroniza el lado propietario (el que se guarda en BD)
            $article->setCategory($this);
        }

        return $this;
    }

    public function removeArticle(Article $article): static
    {
        if ($this->articles->removeElement($article)) {
            // Pone a null el lado propietario (salvo que ya se haya cambiado)
            if ($article->getCategory() === $this) {
                $article->setCategory(null);
            }
        }

        return $this;
    }

    // ... getters y setters de id, name y slug

    public function __toString(): string
    {
        return (string) $this->name;
    }
}

📘 Concepto: Collection es una interfaz de doctrine/collections; ArrayCollection es su implementación en memoria. Cuando Doctrine carga la entidad desde la base de datos, sustituye tu ArrayCollection por una PersistentCollection, que también implementa Collection. Por eso tipas la propiedad con la interfaz.

Métodos útiles de Collection:

Método Qué hace
add($elemento) Añade un elemento al final
removeElement($elemento) Elimina el elemento; devuelve true si existía
contains($elemento) Comprueba si está dentro
count() Número de elementos (también count($coleccion))
isEmpty() true si no tiene elementos
first() / last() Primer / último elemento (o false)
filter(fn) Nueva colección con los que cumplen la condición
map(fn) Nueva colección transformada
toArray() Convierte a array PHP

2.4 Migración

php bin/console make:migration
php bin/console doctrine:migrations:migrate

El SQL generado (MySQL/MariaDB) será parecido a:

CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL,
  slug VARCHAR(120) NOT NULL, UNIQUE INDEX UNIQ_64C19C1989D9B62 (slug), PRIMARY KEY(id));
ALTER TABLE article ADD category_id INT NOT NULL;
ALTER TABLE article ADD CONSTRAINT FK_23A0E6612469DE2 FOREIGN KEY (category_id) REFERENCES category (id);
CREATE INDEX IDX_23A0E6612469DE2 ON article (category_id);

⚠️ Importante: Si ya tienes artículos en la tabla, ADD category_id INT NOT NULL fallará (o les asignará 0, según el motor). En desarrollo lo más sencillo es vaciar la base de datos y recargar fixtures (sección 11). En producción tendrías que crear la columna como nullable, rellenarla y luego hacerla obligatoria en una segunda migración.

2.5 Usar la relación

En cualquier controlador o servicio trabajas con objetos, nunca con identificadores:

// Fragmento de un controlador que recibe EntityManagerInterface $em
$category = new Category();
$category->setName('Symfony');
$category->setSlug('symfony');

$article = new Article();
$article->setTitle('Relaciones en Doctrine');
$article->setSlug('relaciones-en-doctrine');
$article->setContent('Contenido del artículo...');
$article->setCategory($category); // asignamos el objeto, no el id

$em->persist($category);
$em->persist($article);
$em->flush();

echo $article->getCategory()->getName(); // Symfony

En Twig se navega igual de fácil:

{# templates/article/show.html.twig #}
<p class="text-muted">
    Publicado en
    <a href="{{ path('app_category_show', {slug: article.category.slug}) }}">
        {{ article.category.name }}
    </a>
</p>

<h2>Más artículos de {{ article.category.name }}</h2>
<ul>
    {% for related in article.category.articles %}
        <li>{{ related.title }}</li>
    {% endfor %}
</ul>

3. Lado propietario y lado inverso

Esta es la idea que más confusión genera en Doctrine, así que vamos despacio.

📘 Concepto: En toda relación bidireccional hay un lado propietario (owning side) y un lado inverso (inverse side). Doctrine solo mira el lado propietario para decidir qué escribir en la base de datos. El lado inverso es una comodidad para navegar en PHP.

  Lado propietario Lado inverso
¿Dónde está? La entidad que tiene la clave foránea La otra entidad
Atributo que lo indica inversedBy mappedBy
En ManyToOne/OneToMany Siempre el ManyToOne (Article::$category) El OneToMany (Category::$articles)
En ManyToMany El que tú elijas (tiene #[ORM\JoinTable]) El otro
En OneToOne El que tiene la columna *_id El otro
¿Se persisten sus cambios? Sí No, si no se sincroniza el propietario

Una regla mnemotécnica: mappedBy = “a mí me mapea el otro” (soy el inverso); inversedBy = “el otro es mi inverso” (soy el propietario).

El error clásico

// ❌ Solo se modifica el lado inverso a mano
$category->getArticles()->add($article);
$em->flush();
// Resultado: article.category_id NO cambia en la base de datos
// ✅ Se modifica el lado propietario
$article->setCategory($category);
$em->flush();

// ✅ O se usa el método generado, que sincroniza ambos lados
$category->addArticle($article);
$em->flush();

💡 Consejo: Usa siempre los métodos addXxx() / removeXxx() generados por make:entity. Mantienen los dos lados coherentes en memoria y garantizan que el propietario se actualiza. Nunca hagas getArticles()->add() desde fuera de la entidad.

Relaciones unidireccionales

Si al hacer make:entity respondes no a “Do you want to add a new property to Category…”, la relación será unidireccional: solo existirá Article::$category. Es perfectamente válido y más simple. Añade el lado inverso solo si de verdad vas a necesitar $category->getArticles().


4. Article y Comment: cascade, orphanRemoval y onDelete

Los comentarios dependen totalmente de su artículo: un comentario sin artículo no tiene sentido. Es el caso perfecto para estudiar el borrado en cascada.

php bin/console make:entity Comment
# authorName (string 100), content (text), createdAt (datetime_immutable)
# article -> relation -> Article -> ManyToOne -> not null
# ¿Añadir Article::$comments? yes
# ¿orphanRemoval? yes

4.1 La entidad Comment

<?php
// src/Entity/Comment.php
declare(strict_types=1);

namespace App\Entity;

use App\Repository\CommentRepository;
use Doctrine\DBAL\Types\Types;
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(length: 100)]
    private ?string $authorName = null;

    #[ORM\Column(type: Types::TEXT)]
    private ?string $content = null;

    #[ORM\Column]
    private ?\DateTimeImmutable $createdAt = null;

    #[ORM\ManyToOne(inversedBy: 'comments')]
    // onDelete: la propia BD borra los comentarios si se borra el artículo
    #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
    private ?Article $article = null;

    public function __construct()
    {
        $this->createdAt = new \DateTimeImmutable();
    }

    // ... getters y setters
}

4.2 El lado inverso en Article

// src/Entity/Article.php (fragmento)
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;

/**
 * @var Collection<int, Comment>
 */
#[ORM\OneToMany(
    targetEntity: Comment::class,
    mappedBy: 'article',
    cascade: ['persist'],
    orphanRemoval: true,
)]
#[ORM\OrderBy(['createdAt' => 'DESC'])]
private Collection $comments;

public function __construct()
{
    $this->comments = new ArrayCollection();
    $this->createdAt = new \DateTimeImmutable();
}

/**
 * @return Collection<int, Comment>
 */
public function getComments(): Collection
{
    return $this->comments;
}

public function addComment(Comment $comment): static
{
    if (!$this->comments->contains($comment)) {
        $this->comments->add($comment);
        $comment->setArticle($this);
    }

    return $this;
}

public function removeComment(Comment $comment): static
{
    if ($this->comments->removeElement($comment)) {
        if ($comment->getArticle() === $this) {
            $comment->setArticle(null);
        }
    }

    return $this;
}

💡 Consejo: #[ORM\OrderBy] hace que la colección se cargue siempre ordenada. Así en Twig article.comments ya sale del más reciente al más antiguo sin escribir ninguna consulta.

4.3 Tres mecanismos distintos

Opción ¿Quién actúa? Qué hace Cuándo usarla
cascade: ['persist'] Doctrine (PHP) Al hacer persist($article), persiste también sus comentarios nuevos Cuando creas hijos a través del padre
cascade: ['remove'] Doctrine (PHP) Al hacer remove($article), carga y borra cada comentario uno a uno Rara vez; preferible onDelete
orphanRemoval: true Doctrine (PHP) Si sacas un comentario de la colección (removeComment), lo borra de la BD Hijos que no pueden existir sin padre
onDelete: 'CASCADE' La base de datos ON DELETE CASCADE en la clave foránea; la BD borra los hijos Borrado masivo eficiente y seguro
onDelete: 'SET NULL' La base de datos Pone la FK a NULL al borrar el padre (requiere nullable: true) Hijos que sobreviven sin padre
// cascade persist: no hace falta persistir el comentario por separado
$comment = new Comment();
$comment->setAuthorName('Lucía');
$comment->setContent('¡Muy buen artículo!');

$article->addComment($comment);
$em->persist($article); // también persiste $comment gracias a cascade
$em->flush();

// orphanRemoval: quitarlo de la colección lo elimina
$article->removeComment($comment);
$em->flush(); // DELETE FROM comment WHERE id = ?

⚠️ Importante: cascade: ['remove'] y onDelete: 'CASCADE' no son lo mismo. El primero obliga a Doctrine a cargar todos los comentarios en memoria para borrarlos uno a uno (lento con miles de filas). El segundo delega en la base de datos. Para el blog usamos onDelete: 'CASCADE' en Comment::$article.


5. ManyToMany: Article y Tag

Un artículo puede tener varias etiquetas (php, doctrine, tutorial) y una etiqueta aparece en muchos artículos.

php bin/console make:entity Tag
# name (string 50, unique)

php bin/console make:entity Article
# tags -> relation -> Tag -> ManyToMany
# ¿Añadir Tag::$articles? yes

5.1 Lado propietario (Article)

// src/Entity/Article.php (fragmento)

/**
 * @var Collection<int, Tag>
 */
#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
private Collection $tags;

// en el constructor:
// $this->tags = new ArrayCollection();

/**
 * @return Collection<int, Tag>
 */
public function getTags(): Collection
{
    return $this->tags;
}

public function addTag(Tag $tag): static
{
    if (!$this->tags->contains($tag)) {
        $this->tags->add($tag);
    }

    return $this;
}

public function removeTag(Tag $tag): static
{
    $this->tags->removeElement($tag);

    return $this;
}

5.2 Lado inverso (Tag)

// src/Entity/Tag.php (fragmento; la entidad tiene id y name, único)

/**
 * @var Collection<int, Article>
 */
#[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')]
private Collection $articles;

public function __construct()
{
    $this->articles = new ArrayCollection();
}

public function addArticle(Article $article): static
{
    if (!$this->articles->contains($article)) {
        $this->articles->add($article);
        $article->addTag($this); // sincroniza el lado propietario
    }

    return $this;
}

public function removeArticle(Article $article): static
{
    if ($this->articles->removeElement($article)) {
        $article->removeTag($this);
    }

    return $this;
}

Doctrine crea automáticamente la tabla intermedia article_tag con dos columnas (article_id, tag_id) y clave primaria compuesta. Si quieres controlar su nombre:

#[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
#[ORM\JoinTable(name: 'blog_article_tag')]
private Collection $tags;

⚠️ Importante: La tabla intermedia de un ManyToMany solo puede tener las dos claves foráneas. Si necesitas guardar algo más sobre la relación (por ejemplo, “fecha en que se añadió la etiqueta” o “orden”), convierte la relación en una entidad intermedia ArticleTag con dos ManyToOne. Es un cambio habitual cuando el proyecto crece.


6. OneToOne

Una relación uno a uno conecta exactamente un objeto con otro. En el blog la usaremos para separar los datos de perfil público (UserProfile: biografía, web, avatar) de los datos de autenticación (User, módulo 10).

// src/Entity/User.php (fragmento, lado propietario)
#[ORM\OneToOne(cascade: ['persist', 'remove'])]
#[ORM\JoinColumn(nullable: true)]
private ?UserProfile $profile = null;
<?php
// src/Entity/UserProfile.php
declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class UserProfile
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(type: Types::TEXT, nullable: true)]
    private ?string $bio = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $website = null;

    // ... getters y setters
}

💡 Consejo: Antes de crear un OneToOne pregúntate si no bastaría con añadir las columnas a la misma tabla. Tiene sentido cuando los datos se cargan en momentos distintos, son opcionales o pesados (por ejemplo, un JSON grande que no quieres leer en cada login).


7. Carga lazy, eager y el problema N+1

7.1 Carga perezosa (lazy) por defecto

Cuando haces $articleRepository->findAll(), Doctrine solo consulta la tabla article. Las categorías se representan con objetos proxy: objetos “vacíos” que se rellenan la primera vez que accedes a un dato que no sea el id.

$articles = $articleRepository->findAll();     // 1 consulta
echo $articles[0]->getCategory()->getId();     // 0 consultas: el id ya se conoce
echo $articles[0]->getCategory()->getName();   // 1 consulta más: carga la categoría

📘 Concepto: La carga lazy es como pedir en un restaurante solo el plato principal: si luego quieres postre, vuelves a llamar al camarero. Es eficiente si casi nunca pides postre… y desastroso si lo pides para cada comensal de una mesa de 50.

7.2 El problema N+1

{# templates/article/index.html.twig #}
{% for article in articles %}
    <article>
        <h2>{{ article.title }}</h2>
        <span class="badge text-bg-secondary">{{ article.category.name }}</span>
        <small>{{ article.comments|length }} comentarios</small>
    </article>
{% endfor %}

Con 20 artículos, esta plantilla lanza:

  • 1 consulta para los artículos
  • hasta 20 consultas para las categorías (una por categoría distinta)
  • 20 consultas para contar los comentarios

Es decir, 1 + N + N consultas. Lo verás en la barra de depuración de Symfony (icono de base de datos) y en el panel Doctrine del Profiler, que además marca las consultas repetidas.

7.3 Solución: JOIN + addSelect

La solución es pedir todo en una sola consulta con un fetch join: un JOIN cuyos datos se seleccionan (addSelect) para que Doctrine hidrate los objetos relacionados.

// src/Repository/ArticleRepository.php (fragmento)
public function findLatestWithCategory(int $limit = 20): array
{
    return $this->createQueryBuilder('a')
        ->innerJoin('a.category', 'c')
        ->addSelect('c')               // ← clave: hidrata también la categoría
        ->andWhere('a.publishedAt IS NOT NULL')
        ->orderBy('a.publishedAt', 'DESC')
        ->setMaxResults($limit)
        ->getQuery()
        ->getResult();
}
Consulta ¿Hidrata la categoría? Consultas totales
->innerJoin('a.category', 'c') No: solo sirve para filtrar 1 + N
->innerJoin('a.category', 'c')->addSelect('c') Sí 1

⚠️ Importante: Si haces fetch join de una colección (a.comments, a.tags) y además usas setMaxResults(), el límite se aplica a filas SQL, no a artículos, y obtendrás menos artículos de los esperados. En ese caso usa el Paginator de Doctrine (sección 10), que resuelve el problema con dos consultas.

Para contar comentarios sin cargarlos, es mejor una subconsulta o un COUNT agrupado (lo verás en la sección 8.4).

7.4 Otras estrategias de carga

// Siempre cargar la categoría junto al artículo (cuidado: afecta a TODAS las consultas)
#[ORM\ManyToOne(inversedBy: 'articles', fetch: 'EAGER')]
private ?Category $category = null;

// Colecciones enormes: count(), contains() y slice() no cargan la colección entera
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'article', fetch: 'EXTRA_LAZY')]
private Collection $comments;
Modo Comportamiento Uso recomendado
LAZY (por defecto) Carga al acceder Casi siempre
EAGER Carga siempre, con consultas adicionales o JOIN Relaciones pequeñas que usas en todas partes
EXTRA_LAZY Como LAZY, pero count()/contains()/slice() lanzan consultas ligeras Colecciones muy grandes

💡 Consejo: No marques todo como EAGER “por si acaso”. La forma profesional es dejar LAZY y escribir consultas específicas con addSelect() para cada pantalla que lo necesite.


8. Repositorios personalizados con QueryBuilder

8.1 El repositorio generado

make:entity crea un repositorio por entidad:

<?php
// src/Repository/ArticleRepository.php
declare(strict_types=1);

namespace App\Repository;

use App\Entity\Article;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Article>
 */
class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }
}

Ya conoces los métodos heredados (find, findAll, findBy, findOneBy, count). Cuando no bastan, añades métodos propios. Como el repositorio es un servicio, lo inyectas en cualquier controlador o servicio.

8.2 Métodos del QueryBuilder

Método Ejemplo SQL aproximado
createQueryBuilder('a') Alias a para Article SELECT a FROM article a
select() / addSelect() ->addSelect('c') Columnas adicionales
where() ->where('a.slug = :slug') WHERE ... (reemplaza condiciones previas)
andWhere() / orWhere() ->andWhere('a.publishedAt IS NOT NULL') AND ... / OR ...
setParameter() ->setParameter('slug', $slug) Parámetro seguro (sin inyección SQL)
join() / innerJoin() / leftJoin() ->leftJoin('a.tags', 't') JOIN usando la relación
orderBy() / addOrderBy() ->orderBy('a.createdAt', 'DESC') ORDER BY
groupBy() / having() ->groupBy('c.id') GROUP BY / HAVING
setFirstResult() ->setFirstResult(20) OFFSET 20
setMaxResults() ->setMaxResults(10) LIMIT 10
getQuery() Construye el objeto Query  

Y para ejecutar:

Método de Query Devuelve
getResult() Array de entidades
getOneOrNullResult() Una entidad o null (excepción si hay más de una)
getSingleResult() Una entidad (excepción si hay 0 o más de una)
getSingleScalarResult() Un valor escalar (por ejemplo un COUNT)
getArrayResult() Arrays en lugar de objetos (más rápido, solo lectura)

💡 Consejo: Usa siempre andWhere() en lugar de where(). where() sustituye las condiciones anteriores, lo que provoca errores difíciles de ver cuando construyes la consulta en varios pasos.

8.3 Un repositorio completo para el blog

<?php
// src/Repository/ArticleRepository.php
declare(strict_types=1);

namespace App\Repository;

use App\Entity\Article;
use App\Entity\Category;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Doctrine\Persistence\ManagerRegistry;

/**
 * @extends ServiceEntityRepository<Article>
 */
class ArticleRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, Article::class);
    }

    /**
     * Base reutilizable: artículos ya publicados.
     */
    private function createPublishedQueryBuilder(string $alias = 'a'): QueryBuilder
    {
        return $this->createQueryBuilder($alias)
            ->andWhere(sprintf('%s.publishedAt IS NOT NULL', $alias))
            ->andWhere(sprintf('%s.publishedAt <= :now', $alias))
            ->setParameter('now', new \DateTimeImmutable());
    }

    /**
     * @return Article[]
     */
    public function findLatestPublished(int $limit = 10): array
    {
        return $this->createPublishedQueryBuilder()
            ->innerJoin('a.category', 'c')
            ->addSelect('c')
            ->orderBy('a.publishedAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }

    public function findOnePublishedBySlug(string $slug): ?Article
    {
        return $this->createPublishedQueryBuilder()
            ->innerJoin('a.category', 'c')->addSelect('c')
            ->leftJoin('a.tags', 't')->addSelect('t')
            ->andWhere('a.slug = :slug')
            ->setParameter('slug', $slug)
            ->getQuery()
            ->getOneOrNullResult();
    }

    /**
     * @return Article[]
     */
    public function findByCategory(Category $category): array
    {
        return $this->createPublishedQueryBuilder()
            ->andWhere('a.category = :category') // se compara con el objeto
            ->setParameter('category', $category)
            ->orderBy('a.publishedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    /**
     * @return Article[]
     */
    public function findByTagName(string $tagName): array
    {
        return $this->createPublishedQueryBuilder()
            ->innerJoin('a.tags', 't')
            ->andWhere('t.name = :tag')
            ->setParameter('tag', $tagName)
            ->orderBy('a.publishedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }

    /**
     * Búsqueda simple por texto en título o contenido.
     *
     * @return Article[]
     */
    public function search(string $term, int $limit = 20): array
    {
        $qb = $this->createPublishedQueryBuilder();

        return $qb
            ->andWhere($qb->expr()->orX(
                $qb->expr()->like('a.title', ':term'),
                $qb->expr()->like('a.content', ':term'),
            ))
            ->setParameter('term', '%'.$term.'%')
            ->orderBy('a.publishedAt', 'DESC')
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }

    public function countPublished(): int
    {
        return (int) $this->createPublishedQueryBuilder()
            ->select('COUNT(a.id)')
            ->getQuery()
            ->getSingleScalarResult();
    }
}

⚠️ Importante: Nunca concatenes valores del usuario dentro de la consulta ("a.slug = '$slug'"). Usa siempre parámetros (:slug + setParameter). Doctrine los envía por separado al motor de base de datos y así evitas la inyección SQL.

8.4 Resultados que no son entidades

A veces quieres datos agregados, no objetos completos:

// src/Repository/CategoryRepository.php (fragmento)

/**
 * @return array<int, array{id: int, name: string, slug: string, total: int}>
 */
public function findWithArticleCount(): array
{
    return $this->createQueryBuilder('c')
        ->select('c.id', 'c.name', 'c.slug', 'COUNT(a.id) AS total')
        ->leftJoin('c.articles', 'a')
        ->groupBy('c.id')
        ->orderBy('total', 'DESC')
        ->getQuery()
        ->getArrayResult();
}
<ul class="list-group">
    {% for row in categories %}
        <li class="list-group-item d-flex justify-content-between">
            <a href="{{ path('app_category_show', {slug: row.slug}) }}">{{ row.name }}</a>
            <span class="badge text-bg-primary">{{ row.total }}</span>
        </li>
    {% endfor %}
</ul>

8.5 Usar el repositorio en un controlador

<?php
// src/Controller/ArticleController.php
declare(strict_types=1);

namespace App\Controller;

use App\Repository\ArticleRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleController extends AbstractController
{
    #[Route('/articulo/{slug}', name: 'app_article_show')]
    public function show(string $slug, ArticleRepository $articles): Response
    {
        $article = $articles->findOnePublishedBySlug($slug)
            ?? throw $this->createNotFoundException('Artículo no encontrado');

        return $this->render('article/show.html.twig', ['article' => $article]);
    }

    #[Route('/buscar', name: 'app_article_search')]
    public function search(ArticleRepository $articles, #[MapQueryParameter] string $q = ''): Response
    {
        return $this->render('article/search.html.twig', [
            'q' => $q,
            'results' => $q !== '' ? $articles->search($q) : [],
        ]);
    }
}

9. DQL y SQL nativo

9.1 DQL: Doctrine Query Language

📘 Concepto: DQL se parece a SQL, pero trabaja con entidades y propiedades, no con tablas y columnas. Escribes App\Entity\Article y a.publishedAt, no article y published_at. El QueryBuilder no es más que un constructor de DQL.

// src/Repository/ArticleRepository.php (fragmento)
public function findMostCommented(int $limit = 5): array
{
    return $this->getEntityManager()
        ->createQuery(
            'SELECT a, COUNT(c.id) AS HIDDEN totalComments
             FROM App\Entity\Article a
             LEFT JOIN a.comments c
             WHERE a.publishedAt IS NOT NULL
             GROUP BY a.id
             ORDER BY totalComments DESC'
        )
        ->setMaxResults($limit)
        ->getResult();
}

AS HIDDEN permite ordenar por un valor calculado sin que aparezca en el resultado: sigues recibiendo un array de Article.

Puedes ver el DQL de cualquier QueryBuilder con $qb->getDQL() y el SQL final con $qb->getQuery()->getSQL(). También puedes probar DQL desde la terminal:

php bin/console doctrine:query:dql "SELECT a.title FROM App\Entity\Article a WHERE a.publishedAt IS NOT NULL"

9.2 SQL nativo con la conexión DBAL

Para consultas muy específicas del motor (funciones de ventana, full-text, informes complejos) puedes saltarte el ORM y usar la conexión DBAL:

// src/Repository/ArticleRepository.php (fragmento)

/**
 * Artículos publicados por mes (devuelve arrays, no entidades).
 *
 * @return list<array{month: string, total: int}>
 */
public function countPublishedByMonth(): array
{
    $conn = $this->getEntityManager()->getConnection();

    $sql = <<<'SQL'
        SELECT DATE_FORMAT(published_at, '%Y-%m') AS month, COUNT(*) AS total
        FROM article
        WHERE published_at IS NOT NULL AND published_at >= :since
        GROUP BY month
        ORDER BY month DESC
        SQL;

    $result = $conn->executeQuery($sql, [
        'since' => new \DateTimeImmutable('-1 year')->format('Y-m-d'),
    ]);

    return $result->fetchAllAssociative();
}
Método del resultado DBAL Devuelve
fetchAllAssociative() Lista de filas como arrays asociativos
fetchAssociative() Una fila o false
fetchOne() El primer valor de la primera fila
fetchFirstColumn() Lista con la primera columna de cada fila

⚠️ Importante: Con SQL nativo usas nombres de tabla y columna reales (published_at, no publishedAt) y pierdes la portabilidad entre motores (DATE_FORMAT es de MySQL; en PostgreSQL sería TO_CHAR). Úsalo solo cuando DQL se quede corto.

9.3 ¿Qué herramienta elijo?

Herramienta Ventajas Inconvenientes
findBy() Cero código Solo filtros de igualdad simples
QueryBuilder Dinámico, legible, componible Algo más verboso
DQL Muy legible para consultas fijas Cadenas de texto, sin autocompletado
SQL nativo (DBAL) Toda la potencia del motor Devuelve arrays, ata a un motor

10. Paginación

Mostrar 5.000 artículos en una página no es una opción. Doctrine incluye un Paginator que calcula el total y aplica LIMIT/OFFSET correctamente incluso con fetch joins de colecciones.

10.1 Repositorio

// src/Repository/ArticleRepository.php (fragmento)
use Doctrine\ORM\Tools\Pagination\Paginator;

public const int PER_PAGE = 10;

/**
 * @return Paginator<Article>
 */
public function findPublishedPaginated(int $page = 1): Paginator
{
    $query = $this->createPublishedQueryBuilder()
        ->innerJoin('a.category', 'c')->addSelect('c')
        ->leftJoin('a.tags', 't')->addSelect('t')
        ->orderBy('a.publishedAt', 'DESC')
        ->setFirstResult(($page - 1) * self::PER_PAGE)
        ->setMaxResults(self::PER_PAGE)
        ->getQuery();

    // fetchJoinCollection: true porque hacemos JOIN de una colección (tags)
    return new Paginator($query, fetchJoinCollection: true);
}

10.2 Controlador

#[Route('/', name: 'app_home')]
public function index(
    ArticleRepository $articles,
    #[MapQueryParameter] int $page = 1,
): Response {
    $page = max(1, $page);
    $paginator = $articles->findPublishedPaginated($page);
    $totalPages = (int) ceil(count($paginator) / ArticleRepository::PER_PAGE);

    return $this->render('article/index.html.twig', [
        'articles' => $paginator,   // se puede recorrer con for
        'page' => $page,
        'totalPages' => $totalPages,
    ]);
}

10.3 Plantilla con Bootstrap

{# templates/article/index.html.twig #}
{% for article in articles %}
    {{ include('article/_card.html.twig') }}
{% else %}
    <p class="text-muted">Todavía no hay artículos publicados.</p>
{% endfor %}

{% if totalPages > 1 %}
    <nav aria-label="Paginación">
        <ul class="pagination justify-content-center">
            <li class="page-item {{ page <= 1 ? 'disabled' }}">
                <a class="page-link" href="{{ path('app_home', {page: page - 1}) }}">Anterior</a>
            </li>
            {% for p in 1..totalPages %}
                <li class="page-item {{ p == page ? 'active' }}">
                    <a class="page-link" href="{{ path('app_home', {page: p}) }}">{{ p }}</a>
                </li>
            {% endfor %}
            <li class="page-item {{ page >= totalPages ? 'disabled' }}">
                <a class="page-link" href="{{ path('app_home', {page: page + 1}) }}">Siguiente</a>
            </li>
        </ul>
    </nav>
{% endif %}

💡 Consejo: Para proyectos grandes existen bundles que te dan la paginación y su HTML hechos: KnpPaginatorBundle (knplabs/knp-paginator-bundle) y Pagerfanta (babdev/pagerfanta-bundle con su adaptador de Doctrine ORM). Ambos aceptan un QueryBuilder y traen plantillas para Bootstrap 5. Entender el Paginator nativo te ayudará a usarlos con criterio.


11. Fixtures: datos de prueba

Probar un blog con la base de datos vacía es incómodo. Las fixtures son clases que cargan datos de ejemplo con un solo comando.

11.1 DoctrineFixturesBundle

composer require --dev orm-fixtures

Esto instala doctrine/doctrine-fixtures-bundle y crea src/DataFixtures/AppFixtures.php. Una fixture “a mano”:

<?php
// src/DataFixtures/AppFixtures.php
declare(strict_types=1);

namespace App\DataFixtures;

use App\Entity\Article;
use App\Entity\Category;
use App\Entity\Tag;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        $symfony = new Category();
        $symfony->setName('Symfony');
        $symfony->setSlug('symfony');
        $manager->persist($symfony);

        $tagPhp = new Tag();
        $tagPhp->setName('php');
        $manager->persist($tagPhp);

        for ($i = 1; $i <= 5; $i++) {
            $article = new Article();
            $article->setTitle("Artículo de ejemplo $i");
            $article->setSlug("articulo-de-ejemplo-$i");
            $article->setContent('Lorem ipsum dolor sit amet...');
            $article->setPublishedAt(new \DateTimeImmutable("-$i days"));
            $article->setCategory($symfony);
            $article->addTag($tagPhp);
            $manager->persist($article);
        }

        $manager->flush();
    }
}
# Borra TODOS los datos y carga las fixtures
php bin/console doctrine:fixtures:load

# Añade sin borrar
php bin/console doctrine:fixtures:load --append

⚠️ Importante: doctrine:fixtures:load vacía la base de datos antes de cargar. Por eso el bundle se instala con --dev: nunca debe ejecutarse en producción.

Si tienes varias clases de fixtures que dependen entre sí, puedes compartir objetos con $this->addReference('cat-symfony', $symfony) y recuperarlos en otra con $this->getReference('cat-symfony', Category::class), implementando DependentFixtureInterface para fijar el orden.

11.2 Foundry: fábricas de datos realistas

Escribir datos a mano no escala. Foundry (zenstruck/foundry) genera objetos con datos aleatorios realistas usando Faker y se integra con fixtures y tests.

composer require --dev zenstruck/foundry
php bin/console make:factory

make:factory te deja elegir las entidades. Una fábrica para Article ajustada al blog:

<?php
// src/Factory/ArticleFactory.php
declare(strict_types=1);

namespace App\Factory;

use App\Entity\Article;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Zenstruck\Foundry\Persistence\PersistentObjectFactory;

/**
 * @extends PersistentObjectFactory<Article>
 */
final class ArticleFactory extends PersistentObjectFactory
{
    public static function class(): string
    {
        return Article::class;
    }

    protected function defaults(): array|callable
    {
        $title = rtrim(self::faker()->sentence(6), '.');
        $createdAt = \DateTimeImmutable::createFromMutable(
            self::faker()->dateTimeBetween('-1 year', '-1 week')
        );

        return [
            'title' => $title,
            'slug' => new AsciiSlugger()->slug($title)->lower()->toString().'-'.self::faker()->unique()->numberBetween(1, 99999),
            'content' => self::faker()->paragraphs(6, true),
            'createdAt' => $createdAt,
            // 80 % publicados, 20 % borradores
            'publishedAt' => self::faker()->boolean(80) ? $createdAt->modify('+1 day') : null,
            'category' => CategoryFactory::new(),
        ];
    }

    // "Estados" reutilizables
    public function published(): self
    {
        return $this->with(['publishedAt' => new \DateTimeImmutable('-1 day')]);
    }

    public function draft(): self
    {
        return $this->with(['publishedAt' => null]);
    }
}

CategoryFactory, TagFactory y CommentFactory son análogas: en defaults() devuelven name/slug con self::faker()->unique()->word() o, en el caso de Comment, authorName (self::faker()->name()), content (self::faker()->paragraph()) y 'article' => ArticleFactory::new().

11.3 Fixtures con Foundry

<?php
// src/DataFixtures/AppFixtures.php
declare(strict_types=1);

namespace App\DataFixtures;

use App\Factory\ArticleFactory;
use App\Factory\CategoryFactory;
use App\Factory\CommentFactory;
use App\Factory\TagFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager): void
    {
        CategoryFactory::createMany(6);
        TagFactory::createMany(20);

        // 50 artículos, cada uno con categoría y etiquetas existentes
        // y entre 0 y 8 comentarios nuevos
        ArticleFactory::createMany(50, static fn (): array => [
            'category' => CategoryFactory::random(),
            'tags' => TagFactory::randomRange(1, 4),
            'comments' => CommentFactory::new()->range(0, 8),
        ]);

        // Un artículo concreto, útil para probar URLs conocidas
        ArticleFactory::new()->published()->create([
            'title' => 'Bienvenido al blog',
            'slug' => 'bienvenido-al-blog',
        ]);
    }
}
php bin/console doctrine:fixtures:load --no-interaction
Método de Foundry Qué hace
createOne([...]) Crea y persiste un objeto
createMany(n, [...] o fn) Crea n objetos; la función se evalúa para cada uno
new()->many(n) / range(min, max) Colección “por crear” para relaciones OneToMany/ManyToMany
random() Un objeto ya persistido al azar
randomOrCreate() Uno existente o crea uno
randomSet(n) / randomRange(min, max) Varios existentes al azar
with([...]) Estado reutilizable (published(), draft())

💡 Consejo: Foundry brilla todavía más en los tests (módulo 13): ArticleFactory::createOne(['title' => 'X']) en una línea, con la base de datos reiniciada automáticamente entre tests.


12. Errores comunes y buenas prácticas

Error Causa Solución
“A new entity was found through the relationship… that was not configured to cascade persist” Asocias un objeto nuevo sin hacer persist() Haz persist() del objeto relacionado o añade cascade: ['persist']
El cambio en la relación no se guarda Solo modificaste el lado inverso Modifica el propietario o usa addXxx()/removeXxx()
Call to a member function getName() on null Relación opcional vacía Comprueba null (en Twig: article.category.name ?? 'Sin categoría')
Cientos de consultas en el Profiler Problema N+1 JOIN + addSelect() en el repositorio
Menos resultados de los esperados con setMaxResults Fetch join de colección Paginator con fetchJoinCollection: true
Error al borrar una categoría con artículos Restricción de clave foránea Decide: impedirlo en la lógica, onDelete: 'SET NULL' o reasignar
where() borra condiciones anteriores Uso de where en vez de andWhere Usa siempre andWhere()

💡 Consejo: Mantén las consultas en los repositorios, nunca en controladores ni plantillas. Dale a cada método un nombre que exprese la intención de negocio (findLatestPublished), no la técnica (findByPublishedAtNotNullOrderDesc).


13. Ejercicios

Ejercicio 1: Categorías del blog

Crea la entidad Category (name, slug) y la relación ManyToOne obligatoria desde Article. Genera y ejecuta la migración. Crea una ruta /categoria/{slug} que muestre el nombre de la categoría y la lista de sus artículos publicados usando ArticleRepository::findByCategory().

Ejercicio 2: Comentarios con borrado en cascada

Crea la entidad Comment con orphanRemoval: true en Article::$comments y onDelete: 'CASCADE' en la JoinColumn. Comprueba en la base de datos que, al borrar un artículo con $em->remove(), desaparecen sus comentarios. Después, prueba a quitar un comentario con removeComment() y verifica que se elimina de la tabla.

Ejercicio 3: Etiquetas y nube de tags

Crea la relación ManyToMany Article–Tag. Añade a TagRepository un método findPopular(int $limit) que devuelva el nombre de cada etiqueta y el número de artículos que la usan, ordenado de mayor a menor. Muéstralo en la barra lateral con un tamaño de letra proporcional al número de usos.

Ejercicio 4: Caza el N+1

Crea una portada que liste 20 artículos mostrando título, categoría y etiquetas usando findAll(). Anota cuántas consultas muestra la barra de depuración. Después crea un método con JOIN + addSelect() y compara. Escribe en un comentario del repositorio los dos números.

Ejercicio 5: Buscador con filtros combinables

Crea ArticleRepository::searchAdvanced(?string $text, ?Category $category, ?string $tag) que construya la consulta con QueryBuilder añadiendo solo los filtros que no sean null. Conéctalo a una ruta /buscar?q=...&categoria=...&tag=... usando #[MapQueryParameter].

Ejercicio 6: Estadísticas con SQL nativo

Implementa countPublishedByMonth() con DBAL y muestra una tabla con los artículos publicados por mes en los últimos 12 meses en una ruta /estadisticas.

Ejercicio 7: Paginación y fixtures con Foundry

Instala Foundry, crea fábricas para Category, Tag, Article y Comment, y genera 120 artículos con fixtures. Pagina la portada con el Paginator de Doctrine (10 por página) y añade la navegación de Bootstrap. Comprueba que la última página muestra el número correcto de artículos.


Resumen

Concepto Sintaxis / herramienta
Muchos a uno #[ORM\ManyToOne(inversedBy: 'articles')] + #[ORM\JoinColumn(nullable: false)]
Uno a muchos #[ORM\OneToMany(targetEntity: X::class, mappedBy: 'campo')]
Muchos a muchos #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')]
Uno a uno #[ORM\OneToOne]
Lado propietario Tiene la FK; usa inversedBy; es el que se guarda
Lado inverso Usa mappedBy; solo para navegar
Colecciones Collection (interfaz) + new ArrayCollection() en el constructor
Persistir en cascada cascade: ['persist']
Borrar huérfanos orphanRemoval: true
Borrado en BD #[ORM\JoinColumn(onDelete: 'CASCADE')]
Evitar N+1 ->leftJoin('a.category', 'c')->addSelect('c')
QueryBuilder createQueryBuilder('a')->andWhere()->setParameter()->orderBy()->getQuery()->getResult()
DQL $em->createQuery('SELECT a FROM App\Entity\Article a ...')
SQL nativo $conn->executeQuery($sql, $params)->fetchAllAssociative()
Paginación new Paginator($query, fetchJoinCollection: true) + count($paginator)
Fixtures composer require --dev orm-fixtures + doctrine:fixtures:load
Foundry make:factory, XFactory::createMany(), random(), randomRange()