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
mappedByeinversedBy - Trabajar con colecciones (
Collection,ArrayCollection) y los métodosadd/removeque generamake:entity - Controlar el borrado en cascada con
cascade,orphanRemovalyonDelete - 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
Paginatorde 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
relationy 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:
Collectiones una interfaz dedoctrine/collections;ArrayCollectiones su implementación en memoria. Cuando Doctrine carga la entidad desde la base de datos, sustituye tuArrayCollectionpor unaPersistentCollection, que también implementaCollection. 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 NULLfallará (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 pormake:entity. Mantienen los dos lados coherentes en memoria y garantizan que el propietario se actualiza. Nunca hagasgetArticles()->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 Twigarticle.commentsya 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']yonDelete: '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 usamosonDelete: 'CASCADE'enComment::$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
ArticleTagcon 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 usassetMaxResults(), 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 elPaginatorde 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 conaddSelect()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 dewhere().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\Articleya.publishedAt, noarticleypublished_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, nopublishedAt) y pierdes la portabilidad entre motores (DATE_FORMATes de MySQL; en PostgreSQL seríaTO_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-bundlecon su adaptador de Doctrine ORM). Ambos aceptan un QueryBuilder y traen plantillas para Bootstrap 5. Entender elPaginatornativo 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:loadvací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() |