Tu web iba fina con doscientos registros y ahora, con veinte mil, el listado tarda ocho segundos. No es PHP, ni el servidor, ni «que hay mucha gente»: casi siempre son cinco patrones de consulta muy concretos. Estos son, cómo se detectan y cómo se arreglan.
Primero, medir
Antes de tocar nada, abre la barra de depuración de Symfony y mira el icono de Doctrine. Te dice dos números que lo explican casi todo: cuántas consultas se han lanzado y cuánto han tardado. Pincha y verás también las duplicadas, que es donde suele estar el premio gordo.
Si la petición no pasa por el navegador (una API, una llamada de tu front en Vue), el perfil
sigue estando ahí: cada respuesta en dev trae la cabecera
X-Debug-Token-Link con la URL exacta, y el panel de la base de datos se abre
directo:
https://localhost:8000/_profiler/latest?panel=db
Regla de bolsillo: una página normal no debería pasar de 10-20 consultas. Si ves 300, ya sabes qué apartado leer primero.
1. El N+1: 1 consulta que en realidad son 201
El clásico. Cargas cien pedidos y luego, en el bucle o en la plantilla, tocas una relación:
$orders = $this->orderRepository->findBy(['status' => 'paid']); // 1 consulta
foreach ($orders as $order) {
echo $order->getCustomer()->getName(); // +1 consulta por cada pedido
}
Doctrine carga las relaciones de forma perezosa: cada getCustomer() dispara su
propio SELECT. Cien pedidos, ciento una consultas. Y en Twig pasa igual sin que
se vea:
{% for order in orders %}
{{ order.customer.name }} {# aquí también hay un SELECT #}
{% endfor %}
La solución es traértelo todo de una vez con un fetch join:
public function findPaidWithCustomer(): array
{
return $this->createQueryBuilder('o')
->addSelect('c') // sin esto el JOIN no evita el N+1
->innerJoin('o.customer', 'c')
->where('o.status = :status')
->setParameter('status', 'paid')
->getQuery()
->getResult();
}
Ojo al addSelect('c'): si solo haces el join sin seleccionar la
relación, filtras por ella pero no la traes, y el N+1 sigue ahí igual de vivo. Es el error
más repetido cuando alguien intenta arreglar esto por primera vez.
2. Traer la tabla entera
findAll() es cómodo y no pasa nada… hasta que la tabla crece:
$products = $this->productRepository->findAll(); // 40.000 objetos en memoria
Cada fila se convierte en un objeto PHP con todas sus propiedades, y el unit of work de Doctrine guarda además una copia para detectar cambios. Son cientos de megas y varios segundos solo en hidratar.
Para pantallas, pagina siempre:
use Doctrine\ORM\Tools\Pagination\Paginator;
$query = $this->createQueryBuilder('p')
->orderBy('p.createdAt', 'DESC')
->setFirstResult(($page - 1) * $perPage)
->setMaxResults($perPage)
->getQuery();
$paginator = new Paginator($query); // sabe contar el total por ti
Y para procesos por lotes (un comando, una exportación), recorre sin acumular:
$i = 0;
foreach ($query->toIterable() as $product) {
$this->process($product);
if (0 === ++$i % 500) {
$this->em->flush();
$this->em->clear(); // suelta lo ya procesado
}
}
3. Hidratar objetos que no vas a usar
Para pintar un desplegable no necesitas entidades completas con sus relaciones, sus proxies y su seguimiento de cambios. Necesitas dos columnas.
// pesado: 5.000 entidades gestionadas
$all = $this->productRepository->findAll();
// ligero: 5.000 arrays de dos claves
$rows = $this->createQueryBuilder('p')
->select('p.id, p.name')
->getQuery()
->getArrayResult();
Si quieres tipado en lugar de arrays sueltos, DQL sabe construir objetos propios al vuelo:
final readonly class ProductOption
{
public function __construct(
public int $id,
public string $name,
) {
}
}
$options = $this->createQueryBuilder('p')
->select('NEW App\Dto\ProductOption(p.id, p.name)')
->getQuery()
->getResult();
Esos objetos no los gestiona Doctrine: ocupan lo que ocupan y ya está. Para listados de solo lectura es la diferencia entre 300 ms y 20 ms.
4. Contar en PHP lo que sabe contar la base de datos
Esta línea parece inofensiva y es de las peores:
$total = count($user->getOrders()); // trae TODOS los pedidos para contarlos
Acceder a una colección la carga entera. Si el usuario tiene 4.000 pedidos, acabas de traer 4.000 objetos para enseñar un número. Pregúntaselo a la base de datos:
$total = $this->createQueryBuilder('o')
->select('COUNT(o.id)')
->where('o.customer = :customer')
->setParameter('customer', $user)
->getQuery()
->getSingleScalarResult();
Y si necesitas ese count() en muchos sitios, marca la relación como
EXTRA_LAZY: Doctrine hará un COUNT en vez de cargar la colección.
#[ORM\OneToMany(
mappedBy: 'customer',
targetEntity: Order::class,
fetch: 'EXTRA_LAZY',
)]
private Collection $orders;
Con EXTRA_LAZY, count(), contains() y
slice() se resuelven con SQL en lugar de cargarlo todo.
5. La consulta sin índice
Las cuatro anteriores son de código. Esta es de base de datos y es la que aguanta escondida más tiempo, porque con pocos registros no se nota nada.
Coge el SQL real de la consulta lenta —el profiler te lo da formateado— y pásalo por
EXPLAIN:
EXPLAIN SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC LIMIT 20;
Si ves type: ALL y un rows parecido al total de la tabla, estás
leyendo la tabla entera en cada visita. Declara el índice en la entidad y genera la
migración:
#[ORM\Entity]
#[ORM\Table(name: 'orders')]
#[ORM\Index(name: 'idx_status_created', columns: ['status', 'created_at'])]
class Order
{
// ...
}
php bin/console doctrine:migrations:diff
php bin/console doctrine:migrations:migrate
Tres cosas que conviene saber sobre índices: el orden de las columnas importa (primero por
las que filtras, después por las que ordenas), un LIKE '%texto%' no puede usar
un índice normal —eso pide búsqueda de texto completo— y cada índice ralentiza un poco las
escrituras, así que no los pongas «por si acaso».
Lo que se rompe si no lo sabes
- Paginar con un fetch join de colección da resultados raros. El JOIN
duplica filas y el
LIMITcorta por donde no debe. Para eso estáPaginator, que hace la consulta en dos pasos. - Un
WHERE INcon miles de ids es otra bomba. Trocéalo en lotes o cámbialo por una subconsulta. - Los arrays no son entidades. Con
getArrayResult()no hay métodos, ni relaciones, ni cambios que persistir. Úsalo solo para leer. - En producción no hay auto mapping de milagros. Genera el
caché de metadatos y de consultas (
doctrine.orm.metadata_cache) o pagas ese trabajo en cada petición. - El N+1 vuelve por la plantilla. Puedes arreglar el repositorio y que Twig lo reviente al tocar otra relación distinta. Mide después de cambiar, no antes.
- No optimices a ciegas. Dos consultas de 5 ms no son el problema; una de 4 s, sí. El profiler te dice cuál es cuál en diez segundos.
Y ya está
Fetch join para el N+1, paginación en lugar de findAll(), arrays o DTOs cuando
solo vas a leer, COUNT en SQL y un índice donde filtras. Cinco cambios
pequeños que suelen convertir ocho segundos en menos de uno, sin tocar el servidor ni
reescribir nada.