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 Article con make: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:crud y 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 Article y el ORM genera el SQL INSERT, SELECT, UPDATE o DELETE correspondiente.

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.yaml y config/packages/doctrine_migrations.yaml
  • Las carpetas src/Entity/, src/Repository/ y migrations/
  • La variable DATABASE_URL en .env
  • El servicio database en compose.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 siempre serverVersion con la versión real de tu servidor.

💡 Consejo: Si usas el contenedor Docker de la recipe con docker compose up -d, no necesitas tocar DATABASE_URL: Symfony CLI la define automáticamente cuando ejecutas symfony console o symfony server:start. Por eso, en este módulo escribiremos symfony console en lugar de php 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:entity deduce el tipo a partir del nombre: si la propiedad termina en At propone datetime_immutable; si empieza por is o has, boolean. Si tienes Turbo instalado, puede preguntarte además si quieres “broadcast” de cambios: responde no por ahora.

💡 Consejo: Puedes volver a ejecutar make:entity Article en 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 con new Article() todavía no tiene valores. La restricción NOT NULL se 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 _immutable de las fechas (\DateTimeImmutable). Con \DateTime mutable es fácil modificar una fecha sin querer ($fecha->modify('+1 day') cambia el objeto original) y Doctrine no detectaría el cambio.

⚠️ Importante: decimal se devuelve como string para no perder precisión. Para precios, guarda céntimos en un integer o usa decimal y una librería de dinero; nunca float.

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 con find() ya están “vigilados”: basta con modificarlos y llamar a flush(). Doctrine detecta qué ha cambiado y genera el UPDATE solo 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:entity genera uno por entidad (ArticleRepository), que hereda de ServiceEntityRepository y, 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 con AND y un array como valor genera un IN: 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 UNIQUE lanzarí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:crud es una herramienta de aprendizaje y arranque. En proyectos reales, el código generado casi siempre se adapta: se quitan campos del formulario (como createdAt o slug, que son automáticos), se añade seguridad, paginación y mensajes flash. Compara el código generado con tu ArticleAdminController hecho a mano.

⚠️ Importante: El ArticleType generado incluye todos los campos de la entidad, también createdAt y updatedAt. 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