Cuando empiezas con PHP y bases de datos, antes o después te topas con dos opciones: Eloquent (el ORM de Laravel) o Doctrine (el de Symfony). No son equivalentes con distinta sintaxis; parten de filosofías distintas.
Eloquent usa el patrón Active Record: el modelo sabe cómo persistirse a sí mismo. Llamas a $usuario->save() y listo. Es intuitivo, rápido de escribir y perfecto para aplicaciones CRUD donde no necesitas modelar lógica de negocio compleja.
Doctrine usa Data Mapper: la entidad no sabe nada de la base de datos. Es una clase PHP normal que representa tu dominio, y el EntityManager es quien se encarga de persistirla. Más código al principio, sí, pero también más control cuando el dominio se complica.
¿Cuál elegir? Depende del proyecto. Para una API sencilla o un backoffice, Eloquent va sobrado. Para una aplicación con reglas de negocio complejas, múltiples contextos y necesidad de testear sin base de datos, Doctrine marca la diferencia.
Las entidades: clases PHP normales
Una entidad en Doctrine es lo que se llama un POPO (Plain Old PHP Object): una clase PHP sin herencia obligatoria de ninguna clase base. No tienes que extender nada. Doctrine la persiste usando metadatos que le añades con atributos de PHP 8.1.
#[ORMEntity]
#[ORMTable(name: 'usuarios')]
class Usuario
{
#[ORMId]
#[ORMGeneratedValue]
#[ORMColumn]
private int $id;
#[ORMColumn(length: 255)]
private string $nombre;
#[ORMColumn(unique: true)]
private string $email;
public function getNombre(): string
{
return $this->nombre;
}
public function setNombre(string $nombre): void
{
$this->nombre = $nombre;
}
}
Antes de la versión 3.x, el mapeo se hacía con anotaciones de docblock (@ORMEntity) o con ficheros XML. En Doctrine ORM 3 los atributos nativos de PHP son la forma principal, y el resto empieza a quedarse obsoleto. Si tienes proyectos con anotaciones, merece la pena ir migrando.
Otra mejora de la versión 3 es el soporte para readonly classes y enums de PHP 8.1 directamente como tipo de una columna. Puedes mapear un enum nativo sin necesidad de conversiones manuales.
El EntityManager: la pieza central
El EntityManager es tu puerta de entrada a la base de datos. A través de él persistes, buscas y eliminas entidades. Lo más importante es entender que no ejecuta SQL cada vez que llamas a un método: agrupa las operaciones y las lanza todas en el flush().
$em->persist($usuario): marca la entidad para que se inserte o actualice en el siguiente flush.$em->flush(): ejecuta todo el SQL pendiente dentro de una transacción.$em->find(Usuario::class, $id): busca por clave primaria.$em->remove($usuario): marca la entidad para eliminar en el próximo flush.
$usuario = new Usuario();
$usuario->setNombre('Ana García');
$usuario->setEmail('[email protected]');
$em->persist($usuario);
$em->flush();
echo $usuario->getId(); // Ya tiene el ID generado por la BBDD
Detrás de todo esto está el Unit of Work: Doctrine rastrea el estado de todas las entidades que ha cargado o que le has pasado con persist(). Cuando haces flush, calcula qué ha cambiado y genera el SQL mínimo necesario. Si cargas una entidad, modificas una propiedad y haces flush, Doctrine genera un UPDATE solo de esa columna, sin que tengas que decirle nada.
Repositories: las queries en su sitio
El repositorio es donde viven las consultas a la base de datos. Doctrine te da uno por defecto con métodos como find(), findBy() o findOneBy(). Para queries propias, creas un repositorio personalizado.
// Query simple con el repositorio por defecto
$usuario = $em->getRepository(Usuario::class)
->findOneBy(['email' => '[email protected]']);
// Repositorio personalizado
class UsuarioRepository extends EntityRepository
{
public function findActivos(): array
{
return $this->createQueryBuilder('u')
->where('u.activo = :activo')
->setParameter('activo', true)
->orderBy('u.nombre', 'ASC')
->getQuery()
->getResult();
}
}
Doctrine tiene su propio lenguaje de consultas, DQL (Doctrine Query Language), que es parecido a SQL pero trabaja con entidades en lugar de tablas. En vez de SELECT * FROM usuarios, escribes SELECT u FROM AppEntityUsuario u. Doctrine traduce eso al SQL del motor que estés usando.
Relaciones: OneToMany, ManyToOne y ManyToMany
Las relaciones entre entidades se definen también con atributos. Lo más habitual es el ManyToOne/OneToMany entre, por ejemplo, un usuario y sus pedidos.
// En la entidad Pedido (lado ManyToOne, el "owning side")
#[ORMManyToOne(targetEntity: Usuario::class, inversedBy: 'pedidos')]
#[ORMJoinColumn(nullable: false)]
private Usuario $usuario;
// En la entidad Usuario (lado OneToMany, el "inverse side")
#[ORMOneToMany(
targetEntity: Pedido::class,
mappedBy: 'usuario',
cascade: ['persist', 'remove']
)]
private Collection $pedidos;
Hay un concepto que confunde al principio: el owning side y el inverse side. Doctrine genera el SQL de la relación (la foreign key) desde el owning side, que es el que lleva JoinColumn. El inverse side es solo lectura para Doctrine. Si modificas solo el inverse side sin tocar el owning, el cambio no se persiste.
El cascade: ['persist'] es útil cuando creas un usuario con pedidos nuevos y quieres persistirlos todos de un golpe sin llamar a persist() para cada pedido por separado.
Migraciones con Doctrine Migrations
Una vez que tienes las entidades definidas, necesitas que la base de datos refleje esa estructura. Doctrine Migrations compara el esquema que defines en PHP con la base de datos real y genera el SQL de diferencia.
# Genera el fichero de migración con el SQL necesario
php bin/console doctrine:migrations:diff
# Aplica las migraciones pendientes
php bin/console doctrine:migrations:migrate
Cada migración es un fichero PHP con el SQL versionado que va a git. La regla de oro: nunca modifiques una migración ya ejecutada. Si te equivocas, crea una nueva que corrija el error.
Para el despliegue en CI es habitual meter doctrine:migrations:migrate --no-interaction como un paso más del pipeline, antes de reiniciar la aplicación. Así los cambios de esquema van siempre con el código que los necesita.
Si quieres revisar cómo encajan estas migraciones de base de datos en PHP: Doctrine y MySQL con el flujo de despliegue, tienes una guía completa en programacion.net.
El QueryBuilder para filtros dinámicos
Cuando necesitas construir queries que cambian según los parámetros que lleguen (filtros de búsqueda, paginación, orden variable), el QueryBuilder es más cómodo que escribir DQL a mano.
$qb = $em->createQueryBuilder();
$qb->select('u')
->from(Usuario::class, 'u')
->where('u.activo = :activo')
->setParameter('activo', true);
// Filtro opcional: solo si llega el parámetro
if ($nombre) {
$qb->andWhere('u.nombre LIKE :nombre')
->setParameter('nombre', '%' . $nombre . '%');
}
// Paginación
$qb->setFirstResult(($page - 1) * $limit)
->setMaxResults($limit);
$usuarios = $qb->getQuery()->getResult();
El QueryBuilder genera DQL internamente, así que siempre puedes llamar a $qb->getQuery()->getDQL() para ver qué está generando si algo no funciona como esperabas.
Lazy loading y el problema N+1
Por defecto, las asociaciones en Doctrine son lazy: no se cargan hasta que las accedes. Parece cómodo, pero tiene una trampa conocida como el problema N+1.
Imagina que cargas 100 usuarios con una query y luego en el bucle accedes a $usuario->getPedidos() para cada uno. Doctrine lanza una query por cada usuario para cargar sus pedidos: 1 query para los usuarios más 100 queries para los pedidos. 101 en total.
// MAL: genera N+1 queries
$usuarios = $em->getRepository(Usuario::class)->findAll();
foreach ($usuarios as $usuario) {
echo count($usuario->getPedidos()); // Query extra por cada iteración
}
// BIEN: trae todo en una sola query con JOIN FETCH
$usuarios = $em->createQueryBuilder()
->select('u', 'p')
->from(Usuario::class, 'u')
->leftJoin('u.pedidos', 'p')
->addSelect('p')
->getQuery()
->getResult();
El addSelect('p') hace que Doctrine traiga los pedidos en la misma query usando un JOIN. Cuando luego accedes a $usuario->getPedidos(), ya están en memoria y no se lanza ninguna query adicional.
Este es uno de los problemas más habituales en aplicaciones con Doctrine que empiezan a ir lentas. El Symfony Profiler (en la barra de debug) te muestra el número de queries por request, lo que hace que sea fácil detectarlo.
Si te interesa ver cómo Doctrine encaja en un Doctrine y el modelado de dominios complejos en PHP con lógica de negocio exigente, hay más contexto en ese artículo.
Doctrine ORM 3.x: las novedades que más importan
La versión 3 de Doctrine requiere PHP 8.1 como mínimo y consolida varios cambios que venían de versiones anteriores.
Atributos nativos como forma principal de mapeo
Las anotaciones de docblock (@ORMEntity) están deprecadas. Los atributos nativos de PHP (#[ORMEntity]) son ahora la forma oficial. Si tienes proyectos con la sintaxis antigua, hay un conversor automático para migrar.
Lazy objects con PHP 8.4
En lugar de generar clases proxy en disco (que era el mecanismo anterior para el lazy loading), Doctrine 3 aprovecha los lazy objects nativos de PHP 8.4. Menos ficheros generados, menos magia y mejor rendimiento.
Enums de PHP 8.1
Puedes usar un enum nativo de PHP directamente como tipo de columna sin necesidad de conversores personalizados:
enum Estado: string
{
case Activo = 'activo';
case Inactivo = 'inactivo';
}
#[ORMColumn(type: 'string', enumType: Estado::class)]
private Estado $estado;
Doctrine convierte automáticamente entre el valor de la columna y el enum al leer y escribir.
En conjunto, Doctrine ORM 3 es más limpio de configurar que sus versiones anteriores y se aprovecha mejor de lo que PHP ha ido añadiendo. Si arrancas un proyecto nuevo con Symfony, ya no tienes que lidiar con la carga de los ficheros de mapeo en XML ni con los proxies en disco.
Imagen: Pexels / Tima Miroshnichenko
