Módulo 05: Doctrine: entidades
Objetivos del módulo
- Entender qué es un ORM y cómo Doctrine relaciona objetos PHP con tablas
- Instalar y configurar Doctrine con MySQL, PostgreSQL o SQLite
- Crear la base de datos y la entidad
Articleconmake:entity - Conocer los atributos
#[ORM\Entity],#[ORM\Column]y los tipos de columna - Gestionar los cambios del esquema con migraciones
- Guardar, modificar y borrar datos con
EntityManagerInterface - Consultar datos con los métodos de los repositorios
- Construir un CRUD completo en un controlador
- Cargar entidades automáticamente desde la ruta con el value resolver y
#[MapEntity] - Rellenar fechas automáticamente con lifecycle callbacks
- Generar slugs con el componente String
- Generar un CRUD con
make:crudy entender el código generado
1. ORM y Doctrine
📘 Concepto: Un ORM (Object-Relational Mapper) es una capa que traduce entre el mundo de los objetos (tu código PHP) y el mundo de las tablas (la base de datos relacional). Tú trabajas con objetos
Articley el ORM genera el SQLINSERT,SELECT,UPDATEoDELETEcorrespondiente.
Doctrine es el ORM de referencia en Symfony. Se compone de dos capas:
| Capa | Paquete | Qué hace |
|---|---|---|
| DBAL (Database Abstraction Layer) | doctrine/dbal | Conexión a la BD y ejecución de SQL independiente del motor (MySQL, PostgreSQL, SQLite…) |
| ORM | doctrine/orm | Mapeo de objetos a tablas, gestión del ciclo de vida de las entidades, DQL |
Y en Symfony se integra mediante DoctrineBundle y DoctrineMigrationsBundle.
flowchart LR
subgraph PHP["Mundo de objetos (PHP)"]
A1["Article<br/>id: 1<br/>title: Hola, Symfony 8<br/>slug: hola-symfony-8"]
A2["Article<br/>id: 2<br/>title: Twig desde cero"]
end
EM["Doctrine ORM<br/>EntityManager"]
subgraph BD["Mundo relacional (SQL)"]
T[("Tabla article<br/>id / title / slug / content / ...")]
end
A1 <-->|"persist / flush"| EM
A2 <-->|"find / findBy"| EM
EM <-->|"INSERT, SELECT, UPDATE, DELETE"| T
| Mundo de objetos | Mundo relacional |
|---|---|
Clase Article (entidad) | Tabla article |
Propiedad $title | Columna title |
Un objeto new Article() | Una fila |
Propiedad $id | Clave primaria |
$article->getCategory() | Clave foránea category_id (módulo 06) |
📘 Concepto: Doctrine sigue el patrón Data Mapper: tus entidades son clases PHP normales que no saben nada de la base de datos (no tienen métodos
save()ni heredan de nada). Es el EntityManager quien se encarga de guardarlas. Esto hace que el código de negocio sea más limpio y fácil de testear.
2. Instalación y configuración
Instalación
Con --webapp ya tienes todo instalado. En un proyecto skeleton:
composer require symfony/orm-pack
composer require --dev symfony/maker-bundle
symfony/orm-pack instala Doctrine ORM 3, DBAL 4, DoctrineBundle y DoctrineMigrationsBundle, y la recipe crea:
config/packages/doctrine.yamlyconfig/packages/doctrine_migrations.yaml- Las carpetas
src/Entity/,src/Repository/ymigrations/ - La variable
DATABASE_URLen.env - El servicio
databaseencompose.yaml
La variable DATABASE_URL
La conexión se configura con una URL en .env.local (recuerda: no pongas contraseñas reales en .env):
# .env.local
# MySQL
DATABASE_URL="mysql://root:mi_password@127.0.0.1:3306/blog?serverVersion=8.4.0&charset=utf8mb4"
# MariaDB
# DATABASE_URL="mysql://root:mi_password@127.0.0.1:3306/blog?serverVersion=11.4.2-MariaDB&charset=utf8mb4"
# PostgreSQL
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/blog?serverVersion=16&charset=utf8"
# SQLite (un fichero en var/, sin servidor)
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
Formato general:
motor://usuario:contraseña@host:puerto/nombre_bd?serverVersion=X&charset=Y
⚠️ Importante: Si la contraseña contiene caracteres especiales (
@,:,/,#…), debes codificarlos en formato URL (por ejemplo,@→%40). Y define siempreserverVersioncon la versión real de tu servidor.
💡 Consejo: Si usas el contenedor Docker de la recipe con
docker compose up -d, no necesitas tocarDATABASE_URL: Symfony CLI la define automáticamente cuando ejecutassymfony consoleosymfony server:start. Por eso, en este módulo escribiremossymfony consoleen lugar dephp bin/console.
config/packages/doctrine.yaml
# config/packages/doctrine.yaml (fragmento)
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%' # lee DATABASE_URL del entorno
orm:
naming_strategy: doctrine.orm.naming_strategy.underscore # publishedAt → published_at
mappings:
App:
type: attribute # mapeo con atributos PHP
dir: '%kernel.project_dir%/src/Entity' # dónde están las entidades
prefix: 'App\Entity'
Crear la base de datos
symfony console doctrine:database:create
Created database "blog" for connection named default
💡 Consejo: Si la base de datos ya existe, usa
doctrine:database:create --if-not-exists. Para borrarla:doctrine:database:drop --force(¡cuidado, borra todos los datos!).
3. Crear la entidad Article con make:entity
📘 Concepto: Una entidad es una clase PHP cuyos objetos se guardan en la base de datos. Cada entidad corresponde a una tabla.
Para el blog, Article tendrá:
| Propiedad | Tipo | Notas |
|---|---|---|
id | entero | Clave primaria autoincremental |
title | string (255) | Obligatorio |
slug | string (255) | Único, para la URL |
summary | string (500) | Opcional |
content | text | Obligatorio |
publishedAt | datetime_immutable | Opcional (null = borrador) |
createdAt | datetime_immutable | Obligatorio, automático |
updatedAt | datetime_immutable | Opcional, automático |
La sesión interactiva
symfony console make:entity Article
created: src/Entity/Article.php
created: src/Repository/ArticleRepository.php
Entity generated! Now let's add some fields!
You can always add more fields later manually or by re-running this command.
New property name (press <return> to stop adding fields):
> title
Field type (enter ? to see all types) [string]:
>
Field length [255]:
>
Can this field be null in the database (nullable) (yes/no) [no]:
>
updated: src/Entity/Article.php
Add another property? Enter the property name (or press <return> to stop adding fields):
> content
Field type (enter ? to see all types) [string]:
> text
Can this field be null in the database (nullable) (yes/no) [no]:
>
(... repite el proceso con slug, summary, publishedAt, createdAt y updatedAt ...)
Add another property? Enter the property name (or press <return> to stop adding fields):
>
Success!
Next: When you're ready, create a migration with php bin/console make:migration
💡 Consejo:
make:entitydeduce el tipo a partir del nombre: si la propiedad termina enAtproponedatetime_immutable; si empieza porisohas,boolean. Si tienes Turbo instalado, puede preguntarte además si quieres “broadcast” de cambios: respondenopor ahora.
💡 Consejo: Puedes volver a ejecutar
make:entity Articleen cualquier momento para añadir campos a una entidad existente.
La entidad generada
<?php
// src/Entity/Article.php
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)]
private ?string $slug = null;
#[ORM\Column(length: 500, nullable: true)]
private ?string $summary = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $publishedAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function getId(): ?int
{
return $this->id;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
// ... getters y setters del resto de propiedades
}
Anatomía de los atributos
| Atributo | Significado |
|---|---|
#[ORM\Entity(repositoryClass: ...)] | Esta clase es una entidad; su repositorio es ArticleRepository |
#[ORM\Table(name: 'articles')] | (Opcional) Nombre de la tabla si no quieres el predeterminado (article) |
#[ORM\Id] | Esta propiedad es la clave primaria |
#[ORM\GeneratedValue] | El valor lo genera la BD (AUTO_INCREMENT en MySQL, IDENTITY en PostgreSQL) |
#[ORM\Column] | Esta propiedad es una columna; el tipo se deduce del tipo PHP |
length: 255 | Longitud de un VARCHAR |
nullable: true | Admite NULL |
unique: true | Crea un índice único |
type: Types::TEXT | Tipo Doctrine explícito |
options: ['default' => 0] | Valor por defecto en la BD |
📘 Concepto: ¿Por qué todas las propiedades son nullable en PHP (
?string) aunque la columna sea obligatoria? Porque un objeto recién creado connew Article()todavía no tiene valores. La restricciónNOT NULLse aplica en la base de datos y, más adelante, la validación (módulo 08) impedirá guardar datos incompletos.
Mejorando la entidad
Haz el slug único y añade declare(strict_types=1):
#[ORM\Column(length: 255, unique: true)]
private ?string $slug = null;
Y añade un método de dominio útil:
public function isPublished(): bool
{
return $this->publishedAt !== null
&& $this->publishedAt <= new \DateTimeImmutable();
}
En Twig podrás usar {% if article.published %}: Twig llamará a isPublished().
4. Tipos de columna
Doctrine tiene sus propios tipos, que traduce al tipo SQL adecuado de cada motor:
| Tipo Doctrine | Constante | Tipo PHP | MySQL | PostgreSQL |
|---|---|---|---|---|
string | Types::STRING | string | VARCHAR(n) | VARCHAR(n) |
text | Types::TEXT | string | LONGTEXT | TEXT |
integer | Types::INTEGER | int | INT | INT |
smallint | Types::SMALLINT | int | SMALLINT | SMALLINT |
bigint | Types::BIGINT | int o string | BIGINT | BIGINT |
boolean | Types::BOOLEAN | bool | TINYINT(1) | BOOLEAN |
decimal | Types::DECIMAL | string | DECIMAL(p,s) | NUMERIC(p,s) |
float | Types::FLOAT | float | DOUBLE | DOUBLE PRECISION |
datetime_immutable | Types::DATETIME_IMMUTABLE | \DateTimeImmutable | DATETIME | TIMESTAMP |
date_immutable | Types::DATE_IMMUTABLE | \DateTimeImmutable | DATE | DATE |
time_immutable | Types::TIME_IMMUTABLE | \DateTimeImmutable | TIME | TIME |
json | Types::JSON | array | JSON | JSON |
simple_array | Types::SIMPLE_ARRAY | array (de strings) | LONGTEXT (separado por comas) | TEXT |
guid | Types::GUID | string | CHAR(36) | UUID |
uuid / ulid | (Symfony UID) | Uuid / Ulid | BINARY(16) | UUID |
💡 Consejo: Usa siempre las versiones
_immutablede las fechas (\DateTimeImmutable). Con\DateTimemutable es fácil modificar una fecha sin querer ($fecha->modify('+1 day')cambia el objeto original) y Doctrine no detectaría el cambio.
⚠️ Importante:
decimalse devuelve como string para no perder precisión. Para precios, guarda céntimos en unintegero usadecimaly una librería de dinero; nuncafloat.
Enums de PHP
Doctrine mapea enums respaldados (backed enums) con la opción enumType:
<?php
// src/Enum/ArticleStatus.php
declare(strict_types=1);
namespace App\Enum;
enum ArticleStatus: string
{
case Draft = 'draft';
case Review = 'review';
case Published = 'published';
}
#[ORM\Column(length: 20, enumType: ArticleStatus::class)]
private ArticleStatus $status = ArticleStatus::Draft;
5. Migraciones
📘 Concepto: Una migración es una clase PHP que contiene el SQL necesario para llevar la base de datos de una versión a la siguiente (
up()) y para deshacerlo (down()). Las migraciones se versionan en Git: así todo el equipo y el servidor de producción aplican exactamente los mismos cambios en el mismo orden.
Piensa en ellas como el historial de commits, pero de la estructura de tu base de datos.
Generar la migración
symfony console make:migration
created: migrations/Version20261003101530.php
Success!
Review the new migration then run it with php bin/console doctrine:migrations:migrate
Doctrine compara tus entidades con el esquema actual de la BD y genera la diferencia:
<?php
// migrations/Version20261003101530.php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20261003101530 extends AbstractMigration
{
public function getDescription(): string
{
return 'Crea la tabla article'; // ← escribe tú una descripción
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE article (id INT AUTO_INCREMENT NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, summary VARCHAR(500) DEFAULT NULL, content LONGTEXT NOT NULL, published_at DATETIME DEFAULT NULL, created_at DATETIME NOT NULL, updated_at DATETIME DEFAULT NULL, UNIQUE INDEX UNIQ_23A0E66989D9B62 (slug), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
}
public function down(Schema $schema): void
{
$this->addSql('DROP TABLE article');
}
}
(El SQL exacto depende del motor: este es el de MySQL.)
⚠️ Importante: Revisa siempre la migración generada antes de ejecutarla. Doctrine no puede adivinar, por ejemplo, si has renombrado una columna o si has borrado una y creado otra: en ambos casos generará
DROP+ADD, y perderías los datos. En ese caso edita el SQL a mano (ALTER TABLE ... RENAME COLUMN ...).
Ejecutar las migraciones
symfony console doctrine:migrations:migrate
WARNING! You are about to execute a migration in database "blog" that could result in schema changes and data loss. Are you sure you wish to continue? (yes/no) [yes]:
> yes
[notice] Migrating up to DoctrineMigrations\Version20261003101530
[notice] finished in 45.2ms, used 20M memory, 1 migrations executed, 1 sql queries
Doctrine guarda las migraciones ejecutadas en la tabla doctrine_migration_versions, así nunca ejecuta dos veces la misma.
Comandos de migraciones
| Comando | Para qué |
|---|---|
make:migration | Genera una migración comparando entidades y BD |
doctrine:migrations:diff | Equivalente a make:migration |
doctrine:migrations:migrate | Ejecuta todas las migraciones pendientes |
doctrine:migrations:migrate prev | Deshace la última migración (rollback) |
doctrine:migrations:migrate 'DoctrineMigrations\Version20261003101530' | Va a una versión concreta (hacia arriba o abajo) |
doctrine:migrations:status | Resumen: versión actual, ejecutadas, pendientes |
doctrine:migrations:list | Lista todas las migraciones y su estado |
doctrine:migrations:execute VERSION --down | Ejecuta el down() de una migración concreta |
doctrine:migrations:generate | Crea una migración vacía para escribir el SQL a mano |
doctrine:schema:validate | Comprueba que el mapeo es correcto y está sincronizado con la BD |
El flujo de trabajo
flowchart LR
A["Modificar la entidad<br/>(make:entity o a mano)"] --> B["make:migration"]
B --> C["Revisar el SQL generado"]
C --> D["doctrine:migrations:migrate"]
D --> E["git commit<br/>(entidad + migración)"]
E --> A
💡 Consejo: Nunca modifiques una migración que ya se ha ejecutado en otro entorno (en producción o en el ordenador de un compañero). Si necesitas corregir algo, crea una migración nueva.
⚠️ Importante: Existe el comando
doctrine:schema:update --force, que sincroniza la BD directamente sin migraciones. Es cómodo para experimentar, pero no lo uses en proyectos reales: no deja historial y en producción puede destruir datos.
6. El EntityManager: guardar, modificar y borrar
📘 Concepto: El EntityManager (
Doctrine\ORM\EntityManagerInterface) es el objeto central de Doctrine. Lleva la cuenta de las entidades que conoce (la Unit of Work) y, cuando se lo pides, sincroniza todos los cambios con la base de datos.
Crear (INSERT)
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
#[Route('/demo/crear', name: 'demo_create')]
public function create(EntityManagerInterface $em): Response
{
$article = new Article();
$article->setTitle('Hola, Symfony 8');
$article->setSlug('hola-symfony-8');
$article->setContent('Symfony es un framework PHP...');
$article->setCreatedAt(new \DateTimeImmutable());
$em->persist($article); // 1. "Doctrine, vigila este objeto"
$em->flush(); // 2. "Ejecuta ahora el SQL pendiente"
return new Response('Artículo guardado con id ' . $article->getId());
}
| Método | Qué hace |
|---|---|
persist($entidad) | Marca un objeto nuevo para ser guardado. No ejecuta SQL |
flush() | Ejecuta todas las operaciones pendientes en una transacción |
remove($entidad) | Marca un objeto para ser borrado |
getRepository(Article::class) | Devuelve el repositorio de una entidad |
refresh($entidad) | Recarga el objeto desde la BD (descarta cambios no guardados) |
📘 Concepto: ¿Por qué dos pasos? Imagina la lista de la compra:
persist()es apuntar algo en la lista;flush()es ir al supermercado. Puedes apuntar 100 artículos (persist) y hacer un solo viaje (flush): Doctrine agrupa todo el SQL en una transacción, lo que es más eficiente y seguro.
Modificar (UPDATE)
#[Route('/demo/editar/{id}', name: 'demo_update')]
public function update(int $id, EntityManagerInterface $em): Response
{
$article = $em->getRepository(Article::class)->find($id)
?? throw $this->createNotFoundException();
$article->setTitle('Hola, Symfony 8.1');
$em->flush(); // ¡No hace falta persist()! Doctrine ya conoce este objeto
return new Response('Artículo actualizado');
}
⚠️ Importante:
persist()solo es necesario para objetos nuevos. Los que obtienes confind()ya están “vigilados”: basta con modificarlos y llamar aflush(). Doctrine detecta qué ha cambiado y genera elUPDATEsolo con esas columnas.
Borrar (DELETE)
$em->remove($article);
$em->flush();
7. Repositorios: consultar datos
📘 Concepto: Un repositorio es la clase encargada de buscar entidades de un tipo.
make:entitygenera uno por entidad (ArticleRepository), que hereda deServiceEntityRepositoryy, por tanto, ya trae los métodos básicos. Es un servicio: puedes inyectarlo directamente.
<?php
// src/Repository/ArticleRepository.php
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);
}
}
Métodos incluidos
public function examples(ArticleRepository $repo): Response
{
// Por clave primaria → ?Article
$article = $repo->find(1);
// Todos → Article[]
$all = $repo->findAll();
// Por criterios, con orden, límite y desplazamiento → Article[]
$latest = $repo->findBy(
['publishedAt' => null], // WHERE published_at IS NULL
['createdAt' => 'DESC'], // ORDER BY created_at DESC
10, // LIMIT 10
0, // OFFSET 0
);
// Uno por criterios → ?Article
$hola = $repo->findOneBy(['slug' => 'hola-symfony-8']);
// Contar → int
$total = $repo->count();
$drafts = $repo->count(['publishedAt' => null]);
// ...
}
| Método | Devuelve | SQL aproximado |
|---|---|---|
find($id) | ?Article | WHERE id = ? |
findAll() | Article[] | SELECT * FROM article |
findBy($criterios, $orden, $limite, $offset) | Article[] | WHERE ... ORDER BY ... LIMIT ... |
findOneBy($criterios, $orden) | ?Article | WHERE ... LIMIT 1 |
count($criterios) | int | SELECT COUNT(*) ... WHERE ... |
💡 Consejo: Los criterios de
findBy()se combinan conANDy un array como valor genera unIN:findBy(['id' => [1, 2, 3]]). Para consultas más complejas (OR,LIKE, fechas, uniones) escribirás métodos propios con el QueryBuilder.
Un método propio (adelanto del módulo 06)
/**
* Artículos publicados, del más reciente al más antiguo.
*
* @return Article[]
*/
public function findPublished(int $limit = 10): array
{
return $this->createQueryBuilder('a')
->andWhere('a.publishedAt IS NOT NULL')
->andWhere('a.publishedAt <= :now')
->setParameter('now', new \DateTimeImmutable())
->orderBy('a.publishedAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
📘 Concepto: Doctrine mantiene un mapa de identidad (identity map): si pides dos veces el artículo con id 1 en la misma petición, obtienes el mismo objeto y solo se ejecuta una consulta.
8. Cargar entidades desde la ruta
En el módulo 02 hacías find() o findOneBy() y lanzabas un 404 a mano. Doctrine incluye un value resolver (EntityValueResolver) que lo hace por ti.
Por {id}
#[Route('/articulos/{id<\d+>}', name: 'article_show_by_id')]
public function showById(Article $article): Response
{
// Symfony ha hecho $repository->find($id). Si no existe → 404 automático
return $this->render('article/show.html.twig', ['article' => $article]);
}
Por otro campo: {campo:argumento}
#[Route('/articulos/{slug:article}', name: 'article_show')]
public function show(Article $article): Response
{
// findOneBy(['slug' => $slug]) sobre el argumento $article
return $this->render('article/show.html.twig', ['article' => $article]);
}
⚠️ Importante: Solo
{id}se resuelve “por arte de magia”. Para cualquier otro campo usa la sintaxis{slug:article}, que indica explícitamente qué parámetro se usa para qué argumento.
#[MapEntity] para casos avanzados
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
// El parámetro de ruta no se llama "id"
#[Route('/articulos/{article_id}/editar', name: 'article_edit')]
public function edit(#[MapEntity(id: 'article_id')] Article $article): Response { /* ... */ }
// Varios campos
#[Route('/{year}/{slug}', name: 'article_by_year')]
public function byYear(#[MapEntity(mapping: ['slug' => 'slug'])] Article $article, int $year): Response { /* ... */ }
// Con una expresión que llama a un método del repositorio
#[Route('/publicados/{slug}', name: 'article_published')]
public function published(
#[MapEntity(expr: 'repository.findOnePublishedBySlug(slug)')] Article $article,
): Response { /* ... */ }
// Mensaje personalizado del 404 (solo visible en dev)
#[Route('/articulos/{id<\d+>}/imprimir', name: 'article_print')]
public function print(#[MapEntity(message: 'Artículo no encontrado')] Article $article): Response { /* ... */ }
Opción de #[MapEntity] | Para qué |
|---|---|
id | Qué parámetro de ruta contiene la clave primaria |
mapping | Qué parámetros de ruta corresponden a qué propiedades |
exclude | Parámetros de ruta que no se usan para buscar |
expr | Expresión con repository y los parámetros de ruta como variables |
stripNull | Ignora criterios con valor null |
message | Mensaje del 404 en desarrollo |
disabled | Desactiva el resolver para ese argumento |
9. Fechas automáticas: lifecycle callbacks
Es tedioso (y fácil de olvidar) hacer setCreatedAt(new \DateTimeImmutable()) cada vez que creas un artículo. Doctrine puede ejecutar métodos de la entidad en ciertos momentos de su ciclo de vida.
<?php
// src/Entity/Article.php (fragmento)
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ORM\HasLifecycleCallbacks] // ← activa los callbacks en esta entidad
class Article
{
// ...
#[ORM\PrePersist] // justo antes del INSERT
public function onPrePersist(): void
{
$this->createdAt ??= new \DateTimeImmutable();
}
#[ORM\PreUpdate] // justo antes de cada UPDATE
public function onPreUpdate(): void
{
$this->updatedAt = new \DateTimeImmutable();
}
}
| Evento | Cuándo |
|---|---|
PrePersist / PostPersist | Antes / después de insertar |
PreUpdate / PostUpdate | Antes / después de actualizar (solo si algo cambió) |
PreRemove / PostRemove | Antes / después de borrar |
PostLoad | Tras cargar la entidad desde la BD |
⚠️ Importante: No olvides
#[ORM\HasLifecycleCallbacks]en la clase: sin él, los métodos con#[ORM\PrePersist]se ignoran sin dar ningún error.
💡 Consejo: Los lifecycle callbacks son ideales para lógica sencilla que solo afecta a la propia entidad. Si necesitas servicios (enviar un email, acceder a otro repositorio), usa un Doctrine listener (módulo 11).
10. Slugs con el componente String
📘 Concepto: Un slug es una versión de un texto apta para URLs: sin espacios, sin tildes y en minúsculas. “¿Qué hay de nuevo en Symfony 8?” →
que-hay-de-nuevo-en-symfony-8.
El componente String de Symfony incluye AsciiSlugger:
use Symfony\Component\String\Slugger\AsciiSlugger;
$slugger = new AsciiSlugger();
echo $slugger->slug('¿Qué hay de nuevo en Symfony 8?');
// Que-hay-de-nuevo-en-Symfony-8
echo $slugger->slug('¿Qué hay de nuevo en Symfony 8?')->lower();
// que-hay-de-nuevo-en-symfony-8
echo $slugger->slug('Ñandú y Cía', '_')->lower();
// nandu_y_cia
En servicios y controladores, inyecta SluggerInterface (ya configurado con el idioma de la aplicación):
use Symfony\Component\String\Slugger\SluggerInterface;
public function __construct(private readonly SluggerInterface $slugger) {}
// ...
$article->setSlug($this->slugger->slug($article->getTitle())->lower()->toString());
Slug automático en la entidad
Como las entidades no reciben servicios, en un lifecycle callback puedes crear el slugger directamente:
use Symfony\Component\String\Slugger\AsciiSlugger;
#[ORM\PrePersist]
public function onPrePersist(): void
{
$this->createdAt ??= new \DateTimeImmutable();
$this->computeSlug();
}
public function computeSlug(): void
{
if ($this->slug === null || $this->slug === '') {
$this->slug = (new AsciiSlugger())->slug((string) $this->title)->lower()->toString();
}
}
⚠️ Importante: Fíjate en que el slug solo se genera si está vacío y no se recalcula al editar el título. Es intencionado: si cambiaras el slug de un artículo ya publicado, romperías todos los enlaces externos que apuntan a él. Ten en cuenta también que dos artículos con el mismo título generarían el mismo slug y el índice
UNIQUElanzaría una excepción; en el módulo 08 verás cómo validarlo con#[UniqueEntity].
11. CRUD completo en un controlador
Juntemos todo en una zona de administración de artículos. Como los formularios de Symfony llegan en el módulo 07, aquí usamos un formulario HTML sencillo y $request->getPayload().
<?php
// src/Controller/Admin/ArticleAdminController.php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\Article;
use App\Repository\ArticleRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/admin/articulos', name: 'admin_article_')]
final class ArticleAdminController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
) {
}
// READ: listado
#[Route('', name: 'index', methods: ['GET'])]
public function index(ArticleRepository $repository): Response
{
return $this->render('admin/article/index.html.twig', [
'articles' => $repository->findBy([], ['createdAt' => 'DESC']),
]);
}
// CREATE
#[Route('/nuevo', name: 'new', methods: ['GET', 'POST'])]
public function new(Request $request): Response
{
$article = new Article();
if ($request->isMethod('POST')) {
$this->fillFromRequest($article, $request);
$this->em->persist($article);
$this->em->flush();
$this->addFlash('success', 'Artículo creado');
return $this->redirectToRoute('admin_article_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('admin/article/form.html.twig', ['article' => $article]);
}
// UPDATE
#[Route('/{id<\d+>}/editar', name: 'edit', methods: ['GET', 'POST'])]
public function edit(Article $article, Request $request): Response
{
if ($request->isMethod('POST')) {
$this->fillFromRequest($article, $request);
$this->em->flush();
$this->addFlash('success', 'Artículo actualizado');
return $this->redirectToRoute('admin_article_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('admin/article/form.html.twig', ['article' => $article]);
}
// DELETE (solo por POST y con token CSRF)
#[Route('/{id<\d+>}/borrar', name: 'delete', methods: ['POST'])]
public function delete(Article $article, Request $request): Response
{
$token = $request->getPayload()->getString('_token');
if ($this->isCsrfTokenValid('delete' . $article->getId(), $token)) {
$this->em->remove($article);
$this->em->flush();
$this->addFlash('success', 'Artículo eliminado');
} else {
$this->addFlash('danger', 'Token CSRF no válido');
}
return $this->redirectToRoute('admin_article_index', [], Response::HTTP_SEE_OTHER);
}
private function fillFromRequest(Article $article, Request $request): void
{
$data = $request->getPayload();
$article->setTitle($data->getString('title'));
$article->setSummary($data->getString('summary') ?: null);
$article->setContent($data->getString('content'));
$article->setPublishedAt(
$data->getBoolean('published') ? ($article->getPublishedAt() ?? new \DateTimeImmutable()) : null
);
}
}
⚠️ Importante: Este controlador no valida los datos (un título vacío provocaría un error de base de datos) ni está protegido por contraseña. Lo arreglaremos con formularios (07), validación (08) y seguridad (10). Es un ejemplo para entender Doctrine, no código de producción.
La plantilla del listado:
{# templates/admin/article/index.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}Administrar artículos{% endblock %}
{% block body %}
<h1>Artículos <a href="{{ path('admin_article_new') }}" class="btn btn-primary btn-sm">Nuevo</a></h1>
<table class="table table-hover align-middle">
<thead>
<tr><th>Título</th><th>Estado</th><th>Creado</th><th></th></tr>
</thead>
<tbody>
{% for article in articles %}
<tr>
<td>{{ article.title }}</td>
<td>{{ article.published ? 'Publicado' : 'Borrador' }}</td>
<td>{{ article.createdAt|date('d/m/Y H:i') }}</td>
<td class="text-end">
<a href="{{ path('admin_article_edit', {id: article.id}) }}" class="btn btn-sm btn-outline-primary">Editar</a>
<form method="post" action="{{ path('admin_article_delete', {id: article.id}) }}" class="d-inline"
onsubmit="return confirm('¿Seguro que quieres borrar este artículo?');">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ article.id) }}">
<button class="btn btn-sm btn-outline-danger">Borrar</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="4">No hay artículos todavía.</td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
Y el formulario (para crear y editar):
{# templates/admin/article/form.html.twig #}
{% extends 'base.html.twig' %}
{% block title %}{{ article.id ? 'Editar' : 'Nuevo' }} artículo{% endblock %}
{% block body %}
<h1>{{ article.id ? 'Editar «' ~ article.title ~ '»' : 'Nuevo artículo' }}</h1>
<form method="post">
<div class="mb-3">
<label class="form-label" for="title">Título</label>
<input class="form-control" id="title" name="title" value="{{ article.title }}" required>
</div>
<div class="mb-3">
<label class="form-label" for="summary">Resumen</label>
<input class="form-control" id="summary" name="summary" value="{{ article.summary }}" maxlength="500">
</div>
<div class="mb-3">
<label class="form-label" for="content">Contenido</label>
<textarea class="form-control" id="content" name="content" rows="10" required>{{ article.content }}</textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="published" name="published" value="1"
{{ article.published ? 'checked' }}>
<label class="form-check-label" for="published">Publicado</label>
</div>
<button class="btn btn-primary">Guardar</button>
<a href="{{ path('admin_article_index') }}" class="btn btn-link">Cancelar</a>
</form>
{% endblock %}
Actualizar la parte pública del blog
Ahora ArticleController puede olvidarse del ArticleCatalog del módulo 02 y usar el repositorio:
#[Route('/articulos', name: 'article_')]
final class ArticleController extends AbstractController
{
#[Route('', name: 'index', methods: ['GET'])]
public function index(ArticleRepository $repository): Response
{
return $this->render('article/index.html.twig', [
'articles' => $repository->findPublished(20),
]);
}
#[Route('/{slug:article}', name: 'show', methods: ['GET'])]
public function show(Article $article): Response
{
if (!$article->isPublished()) {
throw $this->createNotFoundException('Artículo no publicado');
}
return $this->render('article/show.html.twig', ['article' => $article]);
}
}
💡 Consejo: Abre el panel Doctrine de la Web Debug Toolbar en cada página: verás cada consulta SQL ejecutada, su tiempo y un botón para ver el plan de ejecución (explain). Es la mejor forma de aprender qué SQL genera Doctrine.
12. Generar un CRUD con make:crud
Maker puede generar un CRUD completo con formularios de Symfony a partir de una entidad:
symfony console make:crud Article
Choose a name for your controller class (e.g. ArticleController) [ArticleController]:
> Admin\ArticleCrudController
Do you want to generate PHPUnit tests? (yes/no) [no]:
> no
created: src/Controller/Admin/ArticleCrudController.php
created: src/Form/ArticleType.php
created: templates/admin/article_crud/ (_delete_form, _form, edit, index, new y show)
(Los nombres exactos de carpetas y rutas dependen del nombre que elijas para el controlador.)
Revisando el código generado
#[Route('/admin/article/crud')]
final class ArticleCrudController extends AbstractController
{
#[Route(name: 'app_admin_article_crud_index', methods: ['GET'])]
public function index(ArticleRepository $articleRepository): Response
{
return $this->render('admin/article_crud/index.html.twig', [
'articles' => $articleRepository->findAll(),
]);
}
#[Route('/new', name: 'app_admin_article_crud_new', methods: ['GET', 'POST'])]
public function new(Request $request, EntityManagerInterface $entityManager): Response
{
$article = new Article();
$form = $this->createForm(ArticleType::class, $article);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($article);
$entityManager->flush();
return $this->redirectToRoute('app_admin_article_crud_index', [], Response::HTTP_SEE_OTHER);
}
return $this->render('admin/article_crud/new.html.twig', [
'article' => $article,
'form' => $form,
]);
}
#[Route('/{id}', name: 'app_admin_article_crud_show', methods: ['GET'])]
public function show(Article $article): Response { /* ... */ }
#[Route('/{id}/edit', name: 'app_admin_article_crud_edit', methods: ['GET', 'POST'])]
public function edit(Request $request, Article $article, EntityManagerInterface $entityManager): Response { /* ... */ }
#[Route('/{id}', name: 'app_admin_article_crud_delete', methods: ['POST'])]
public function delete(Request $request, Article $article, EntityManagerInterface $entityManager): Response
{
if ($this->isCsrfTokenValid('delete'.$article->getId(), $request->getPayload()->getString('_token'))) {
$entityManager->remove($article);
$entityManager->flush();
}
return $this->redirectToRoute('app_admin_article_crud_index', [], Response::HTTP_SEE_OTHER);
}
}
Qué observar:
| Detalle | Por qué es así |
|---|---|
Article $article en show, edit, delete | Usa el value resolver por {id} (404 automático) |
persist() solo en new | En edit la entidad ya está gestionada: basta flush() |
Response::HTTP_SEE_OTHER (303) | Patrón Post/Redirect/Get: tras un POST se redirige con 303 para que recargar no reenvíe el formulario |
delete solo por POST y con CSRF | Borrar con un simple enlace GET sería peligroso (un bot o un <img> malicioso podría borrar datos) |
Mismo path /{id} para show (GET) y delete (POST) | Las rutas se distinguen por el método HTTP |
createForm(ArticleType::class, ...) | Formularios de Symfony: los verás a fondo en el módulo 07 |
💡 Consejo:
make:crudes una herramienta de aprendizaje y arranque. En proyectos reales, el código generado casi siempre se adapta: se quitan campos del formulario (comocreatedAtoslug, que son automáticos), se añade seguridad, paginación y mensajes flash. Compara el código generado con tuArticleAdminControllerhecho a mano.
⚠️ Importante: El
ArticleTypegenerado incluye todos los campos de la entidad, tambiéncreatedAtyupdatedAt. Elimínalos del formulario, ya que se rellenan solos con los lifecycle callbacks.
13. Errores comunes
| Error | Causa | Solución |
|---|---|---|
An exception occurred in the driver: could not find driver | Falta la extensión PDO del motor | Activa pdo_mysql / pdo_pgsql / pdo_sqlite en php.ini |
SQLSTATE[HY000] [2002] Connection refused | El servidor de BD no está arrancado o el puerto es incorrecto | docker compose ps o revisa DATABASE_URL |
SQLSTATE[HY000] [1045] Access denied | Usuario o contraseña incorrectos | Revisa DATABASE_URL en .env.local |
Table 'blog.article' doesn't exist | No se han ejecutado las migraciones | doctrine:migrations:migrate |
Integrity constraint violation: 1048 Column 'title' cannot be null | Guardas una entidad sin un campo obligatorio | Rellena el campo (y valida, módulo 08) |
Duplicate entry ... for key 'UNIQ_...' | Valor repetido en una columna única (slug) | Genera slugs únicos / #[UniqueEntity] |
No changes detected in your mapping information | make:migration no ve cambios | ¿Guardaste la entidad? ¿Falta #[ORM\Column]? |
La fecha createdAt no se rellena | Falta #[ORM\HasLifecycleCallbacks] | Añádelo a la clase |
"App\Entity\Article" object not found by "...\EntityValueResolver" | No existe la entidad buscada (404) o la ruta usa un parámetro que no es id sin {slug:article} | Revisa la ruta y la sintaxis del mapeo |
14. Ejercicios
Ejercicio 1: Conectar la base de datos
Configura DATABASE_URL (Docker o MySQL local), crea la base de datos con doctrine:database:create y comprueba la conexión con doctrine:schema:validate (la parte de mapeo dará OK aunque aún no haya tablas).
Ejercicio 2: La entidad Article
Crea la entidad Article con make:entity y todos los campos de la sección 3. Haz el slug único, genera la migración, revísala y ejecútala. Comprueba con doctrine:migrations:status que no queda nada pendiente y mira la tabla con tu cliente de BD favorito.
Ejercicio 3: Añadir campos
Añade a Article un campo views (entero, no nulo, valor por defecto 0) y un campo status con el enum ArticleStatus. Genera y ejecuta la migración. Después prueba a deshacerla con doctrine:migrations:migrate prev y vuelve a aplicarla.
Ejercicio 4: Fechas y slug automáticos
Añade #[ORM\HasLifecycleCallbacks] con PrePersist (rellena createdAt y el slug) y PreUpdate (rellena updatedAt). Crea una ruta /demo/crear que guarde un artículo con el título “¿Qué hay de nuevo en Symfony 8.1?” sin llamar a setSlug() ni setCreatedAt() y comprueba en la BD que ambos campos se han rellenado.
Ejercicio 5: El CRUD de administración
Implementa ArticleAdminController con sus plantillas (sección 11). Crea tres artículos, publica dos, edita uno y borra otro. Observa en el panel Doctrine del Profiler qué SQL se ejecuta en cada operación.
Ejercicio 6: La parte pública con Doctrine
Sustituye ArticleCatalog por ArticleRepository en ArticleController y PageController: el listado debe mostrar solo los artículos publicados (findPublished()), el detalle debe cargar el artículo con {slug:article} y devolver 404 si es un borrador, y la portada debe mostrar los tres últimos. Elimina después el servicio ArticleCatalog (y adapta la extensión Twig del módulo 03 si lo usaba).
Ejercicio 7: Contador de visitas
En la acción show, incrementa el campo views del artículo y guarda el cambio con flush(). Muestra el número de visitas en la plantilla. ¿Necesitas llamar a persist()? Explica por qué.
Ejercicio 8: make:crud
Genera un CRUD con make:crud Article usando el nombre Admin\ArticleCrudController. Compáralo con tu controlador hecho a mano y anota al menos cinco diferencias. Elimina createdAt, updatedAt y slug del ArticleType generado y prueba a crear un artículo con él.
Resumen
| Concepto | Sintaxis / comando |
|---|---|
| Instalar | composer require symfony/orm-pack (incluido en --webapp) |
| Conexión | DATABASE_URL="mysql://user:pass@127.0.0.1:3306/blog?serverVersion=8.4.0" en .env.local |
| Crear BD | doctrine:database:create |
| Crear/editar entidad | make:entity Article |
| Entidad | #[ORM\Entity(repositoryClass: ArticleRepository::class)] |
| Clave primaria | #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] |
| Columna | #[ORM\Column(length: 255, nullable: true, unique: true)] |
| Tipo explícito | #[ORM\Column(type: Types::TEXT)] |
| Enum | #[ORM\Column(enumType: ArticleStatus::class)] |
| Generar migración | make:migration |
| Ejecutar migraciones | doctrine:migrations:migrate |
| Deshacer | doctrine:migrations:migrate prev |
| Estado | doctrine:migrations:status |
| Guardar nuevo | $em->persist($a); $em->flush(); |
| Modificar | Cambiar el objeto + $em->flush(); |
| Borrar | $em->remove($a); $em->flush(); |
| Buscar | find(), findAll(), findBy(), findOneBy(), count() |
| Desde la ruta | {id} o {slug:article} + Article $article |
| Avanzado | #[MapEntity(id: ..., mapping: ..., expr: ...)] |
| Callbacks | #[ORM\HasLifecycleCallbacks] + #[ORM\PrePersist], #[ORM\PreUpdate] |
| Slug | (new AsciiSlugger())->slug($titulo)->lower() o SluggerInterface |
| CRUD generado | make:crud Article |