Cómo detectar y eliminar el problema N+1 de consultas en Laravel

El problema N+1 ocurre cuando haces una consulta para obtener una lista de registros y luego, para cada uno de ellos, lanzas otra consulta adicional. El resultado es que en vez de dos consultas acabas con N+1, donde N es el número de registros.

El ejemplo clásico: tienes 100 posts y quieres mostrar el nombre del autor de cada uno.

$posts = Post::all();

foreach ($posts as $post) {
    echo $post->author->name;
}

Esto lanza una consulta para traer los 100 posts y luego 100 consultas más, una por cada acceso a $post->author. Total: 101 consultas para lo que debería ser un problema de dos.

Eloquent lo hace muy fácil de cometer porque las relaciones se cargan de forma transparente. Escribes $post->author y Eloquent va a la base de datos sin avisar. Si estás iterando una colección, cada acceso a una relación no cargada dispara su propia query. La comodidad tiene un precio.

Cómo detectarlo

Antes de solucionar nada, necesitas ver qué está pasando. Hay varias herramientas para esto.

Laravel Debugbar

Es el punto de partida para desarrollo local. Instala el paquete con Composer:

composer require barryvdh/laravel-debugbar --dev

Una vez activo, verás en el navegador una barra con todas las consultas ejecutadas en cada petición: su SQL, el tiempo que tardó cada una y cuántas hay en total. Si ves 87 consultas casi idénticas que solo cambian el id, ahí está el problema.

Laravel Telescope

Para staging o producción (con cuidado del volumen de datos que genera), Telescope registra todas las queries con su contexto. Es útil cuando no puedes reproducir el problema en local o cuando quieres monitorizarlo a lo largo del tiempo.

composer require laravel/telescope
php artisan telescope:install
php artisan migrate

DB::listen() para log manual

Si quieres algo más ligero, puedes escuchar todas las queries y escribirlas en el log de Laravel:

DB::listen(function ($query) {
    Log::debug($query->sql, $query->bindings);
});

Ponlo en un ServiceProvider o en el middleware de desarrollo. No es tan visual como Debugbar pero funciona sin instalar nada.

withQueryLog() en tests

En tests o en scripts puntuales puedes capturar el log de queries así:

DB::enableQueryLog();

$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name;
}

$queries = DB::getQueryLog();
dd(count($queries)); // te dice cuántas queries se lanzaron

Es directo y sin dependencias. Muy útil para escribir un test que falle si el número de queries supera un umbral.

Clockwork

Alternativa a Debugbar con una extensión de Chrome/Firefox dedicada. Algunos prefieren su interfaz, sobre todo para APIs donde no hay página HTML que mostrar. Se instala con composer require itsgoingd/clockwork --dev.

Eager loading con with()

La solución principal al N+1 es el eager loading: cargar las relaciones de antemano, junto con la consulta principal.

// Con N+1 (malo):
$posts = Post::all();

// Con eager loading (bien):
$posts = Post::with('author')->get();

Con with('author') Laravel ejecuta exactamente dos consultas: una para los posts y otra para todos sus autores a la vez. Da igual si tienes 10 o 10.000 posts.

Relaciones anidadas

Puedes cargar relaciones en cadena usando la notación de punto:

$posts = Post::with('author.profile')->get();

Esto carga los posts, los autores de esos posts y el perfil de cada autor, todo en tres consultas.

Seleccionar solo las columnas necesarias

Por defecto with() trae todas las columnas de la relación. Si solo necesitas el nombre del autor, díselo:

$posts = Post::with(['author:id,name'])->get();

El id es obligatorio: Eloquent lo necesita para enlazar los registros. El resto, solo lo que uses.

withCount()

Cuando solo quieres saber cuántos registros relacionados hay, sin cargarlos todos:

$posts = Post::withCount('comments')->get();

// Accedes al conteo así:
foreach ($posts as $post) {
    echo $post->comments_count;
}

Una sola consulta adicional con un COUNT en SQL en vez de traerte todos los comentarios de todos los posts.

Lazy eager loading con load()

A veces ya tienes la colección y no puedes (o no quieres) modificar la consulta que la generó. Para eso existe load():

$posts = Post::all(); // ya tienes la colección

$posts->load('author'); // cargas la relación después

Esto lanza una sola consulta para todos los autores, igual que with(). La diferencia es que lo haces sobre una colección ya existente. Es especialmente útil en servicios que reciben colecciones como parámetro y necesitan enriquecerlas sin saber cómo se construyeron.

has() y whereHas() para filtrar por relaciones

Otro error frecuente: traerte todos los registros y filtrar en PHP en vez de hacerlo en la base de datos.

// Mal:
$posts = Post::all()->filter(fn($p) => $p->comments->count() > 0);

// Bien:
$posts = Post::has('comments')->get();

Con has() el filtro ocurre en SQL y solo obtienes los posts que tienen comentarios. Una consulta, sin iterar nada en PHP.

Si necesitas condiciones sobre la relación, usa whereHas():

$posts = Post::whereHas('comments', function ($query) {
    $query->where('approved', true);
})->get();

Solo posts que tienen al menos un comentario aprobado, resuelto con un EXISTS en SQL.

Relaciones polimórficas y N+1

Las relaciones polimórficas con morphTo() son el caso más peligroso. Cuando tienes un modelo que puede pertenecer a varios tipos distintos (por ejemplo, Comment que puede pertenecer a Post, Video o Product), el eager loading estándar no sabe de antemano qué modelos va a necesitar.

// Así se carga la relación polimórfica:
$comments = Comment::with('commentable')->get();

Esto funciona, pero si los comentarios apuntan a muchos tipos distintos, Laravel lanza una consulta por cada tipo. Para controlarlo mejor, usa morphWith() para especificar qué relaciones cargar según el tipo:

use IlluminateDatabaseEloquentRelationsMorphTo;

$comments = Comment::with(['commentable' => function (MorphTo $morphTo) {
    $morphTo->morphWith([
        Post::class => ['author'],
        Video::class => ['channel'],
    ]);
}])->get();

Así controlas exactamente qué se carga para cada tipo y evitas queries inesperadas.

preventLazyLoading() en desarrollo

Laravel tiene una opción que convierte el N+1 en un error explícito durante el desarrollo. En AppServiceProvider::boot():

use IlluminateDatabaseEloquentModel;

public function boot(): void
{
    Model::preventLazyLoading(!app()->isProduction());
}

Con esto activo, si accedes a una relación que no has cargado con with(), Laravel lanza una excepción en vez de hacer la query en silencio. Te obliga a ser explícito con tus relaciones desde el principio.

El condicional !app()->isProduction() es importante. En producción no actives esto: si hay un bug en el código que accede a una relación no cargada, es mejor que vaya lento a que explote con una excepción para el usuario. En desarrollo, en cambio, la excepción es exactamente lo que quieres: que el problema sea imposible de ignorar.

Medir el impacto real

Con Debugbar puedes ver antes y después de forma muy clara. Un ejemplo real de un panel de administración con 200 registros:

  • Antes: 201 queries, 1.4 segundos de tiempo en base de datos.
  • Después de añadir with('author', 'category'): 3 queries, 48 ms.

No hace falta hacer cálculos. Abres Debugbar, miras el contador de queries, ves si hay muchas muy parecidas y aplicas with() donde corresponde. El cambio es inmediato y se mide solo.

Si quieres capturar esto en un test para que no vuelva a romperse:

public function test_panel_no_dispara_n_plus_1()
{
    Post::factory(50)->create();

    DB::enableQueryLog();

    $this->get('/admin/posts');

    $queries = DB::getQueryLog();

    $this->assertLessThan(10, count($queries));
}

Así cualquier regresión queda atrapada en CI antes de llegar a producción.

Para más contexto sobre cómo organizar el código Laravel para que escale bien, puedes ver el artículo sobre arquitectura escalable en Laravel. Y si quieres entender el rendimiento desde una perspectiva más amplia, el artículo sobre optimización real de rendimiento en PHP tiene mucho detalle sobre lo que pasa debajo del capó.

Imagen: Pexels / Pixabay

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP