Optimización de queries N+1 en Laravel con Eloquent
Optimización de queries N+1 en Laravel con Eloquent
Uno de los problemas de performance más comunes —y simultáneamente uno de los más silenciados en code reviews— es el problema de las queries N+1 en aplicaciones Laravel. Si trabajás con Eloquent y PostgreSQL, es prácticamente inevitable encontrarte con este patrón antipattern, especialmente en codebases que crecieron rápidamente sin la supervisión de herramientas de profiling adecuadas. En este artículo vamos a desglosar el problema en detalle, explorar cómo detectarlo y aplicar las soluciones específicas que Laravel pone a disposición.
¿Qué es exactamente el problema N+1?
El problema N+1 ocurre cuando tu código ejecuta una query para obtener un conjunto de registros, y luego ejecuta una query adicional por cada registro de ese conjunto para obtener datos relacionados. El resultado es 1 query inicial + N queries adicionales, donde N es la cantidad de registros del resultado principal.
Con PostgreSQL, cada query independiente implica:
- Establecimiento de la conexión o reutilización del pool
- Parseo y planificación del query planner
- Network round-trip entre aplicación y base de datos
- Ejecución del plan de consulta
- Serialización y transferencia de resultados
Cuando multiplicás esto por cientos o miles de iteraciones, el impacto es catastrófico.
Reproduciendo el problema en Laravel
Veamos el escenario clásico. Tenemos dos modelos: Post y Author, con una relación belongsTo.
// app/Models/Post.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Post extends Model
{
use HasFactory;
public function author(): BelongsTo
{
return $this->belongsTo(Author::class);
}
}
// app/Models/Author.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Author extends Model
{
use HasFactory;
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
Ahora, en un controlador, el patrón problemático:
// app/Http/Controllers/PostController.php
namespace App\Http\Controllers;
use App\Models\Post;
class PostController extends Controller
{
public function index()
{
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name;
}
return view('posts.index', compact('posts'));
}
}
Con 500 posts, esto genera 501 queries: 1 para obtener todos los posts, y 500 para obtener el autor de cada uno. En PostgreSQL, podés verificarlo habilitando el query log:
// En tu archivo de configuración de base de datos o runtime
\DB::enableQueryLog();
$posts = Post::all();
foreach ($posts as $post) {
$post->author->name;
}
dd(\DB::getQueryLog());
Detección automatizada en desarrollo
Laravel incluye una herramienta excepcional para detectar N+1 desde el framework mismo. Desde Laravel 8, el service provider Illuminate\Database\Eloquent\DetectsLostConnections evolucionó, y ahora contamos con el evento Model::preventLazyLoading() en entornos de desarrollo y testing.
Configuralo en tu AppServiceProvider:
// app/Providers/AppServiceProvider.php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
}
Cuando esté habilitado, cualquier acceso lazy a una relación no cargada lanzará una Illuminate\Database\LazyLoadingViolationException. Esto es fundamental: transforma un problema silencioso de performance en una excepción explícita durante el desarrollo.
Para testing, Laravel 9+ incorporó preventSilentlyDiscardingAttributes y métodos de assertion sobre queries:
use Illuminate\Support\Facades\DB;
public function test_posts_index_does_not_trigger_n_plus_one()
{
Post::factory()->count(10)->create();
DB::enableQueryLog();
$this->getJson('/api/posts');
$queries = collect(DB::getQueryLog())->pluck('query');
$selectAuthors = $queries->filter(
fn ($query) => str_contains($query, 'from "authors"')
)->count();
$this->assertEquals(1, $selectAuthors);
}
Eager Loading con with(): la solución fundamental
El mecanismo principal de Laravel para resolver N+1 es el eager loading mediante el método with(). Este método instruye a Eloquent para que, en la query inicial, JOINe o realice una query adicional preparatoria que cargue todas las relaciones necesarias de una sola vez.
La corrección del ejemplo anterior:
public function index()
{
$posts = Post::with('author')->get();
foreach ($posts as $post) {
echo $post->author->name; // Acceso desde memoria, sin query adicional
}
return view('posts.index', compact('posts'));
}
Con esta modificación, el total de queries pasa de 501 a 2:
select * from "posts"select * from "authors" where "authors"."id" in (1, 2, 3, ..., N)
PostgreSQL ejecuta la segunda query con una cláusula IN optimizada, resolviendo todas las relaciones en una sola operación sequencial o index scan.
Relaciones anidadas y eager loading múltiple
El problema se complica con relaciones anidadas. Suponé que Author tiene una relación country y Post tiene además comentarios (comments) que a su vez tienen usuario (user).
// Carga múltiple y anidada
$posts = Post::with([
'author.country',
'comments.user'
])->get();
Esto ejecuta:
- 1 query para posts
- 1 query para authors (filtrados por post.author_id)
- 1 query para countries (filtrados por author.country_id)
- 1 query para comments (filtrados por comment.post_id)
- 1 query para users (filtrados por comment.user_id)
Total: 5 queries independientemente de la cantidad de registros. Sintaxis alternativa para mayor claridad:
$posts = Post::with('author:id,name,country_id')
->with('author.country:id,name,code')
->with('comments:id,content,user_id,post_id')
->with('comments.user:id,name,email')
->get();
Select específico de columnas
Un detalle crítico en producción con PostgreSQL: al especificar columnas en relaciones eager loaded, hay que incluir siempre las foreign keys que Eloquent necesita para reconstruir el modelo. Si omitís country_id en author, la relación author.country fallará silenciosamente o lanzará excepciones.
// INCORRECTO: country_id falta, la relación country no puede resolverse
$posts = Post::with('author:id,name')->get();
// CORRECTO: incluí todas las keys necesarias
$posts = Post::with('author:id,name,country_id')->get();
Lazy eager loading: when needed
Hay escenarios donde no sabés de antemano si vas a necesitar una relación. Para estos casos, Laravel ofrece load() sobre una colección ya obtenida:
$posts = Post::all();
if ($someCondition) {
$posts->load('author');
}
// Ahora safe para iterar
foreach ($posts as $post) {
$post->author?->name;
}
Esto es útil en servicios donde la lógica condicional determina qué datos presentar, manteniendo la query base ligera.
Relaciones hasMany: el problema inverso
El N+1 también aparece en la dirección inversa. Si listás autores con sus posts:
// PROBLEMÁTICO
$authors = Author::all();
foreach ($authors as $author) {
foreach ($author->posts as $post) {
echo $post->title;
}
}
La solución idéntica:
// CORRECTO
$authors = Author::with('posts')->get();
Carga condicional withWhereHas
Para filtrar el modelo principal basándote en condiciones de la relación, usá withWhereHas (Laravel 10+) o la combinación whereHas + with:
use Illuminate\Database\Eloquent\Builder;
// Posts que tienen comentarios aprobados, con esos comentarios cargados
$posts = Post::withWhereHas('comments', function (Builder $query) {
$query->where('approved', true);
})->get();
// Equivalente en versiones anteriores
$posts = Post::whereHas('comments', function (Builder $query) {
$query->where('approved', true);
})
->with(['comments' => function (Builder $query) {
$query->where('approved', true);
}])
->get();
Optimización a nivel de PostgreSQL: índices estratégicos
Aunque eager loading resuelve el problema N+1, las queries resultantes deben ejecutarse eficientemente en PostgreSQL. Verificá los planes de ejecución:
EXPLAIN ANALYZE
SELECT * FROM "authors" WHERE "authors"."id" IN (1, 2, 3, ..., 500);
Asegurate de tener índices en las foreign keys:
CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_authors_country_id ON authors(country_id);
Para colecciones muy grandes, considerá que el query con IN de eager loading puede degradarse. PostgreSQL optimiza bien hasta cierto tamaño; para miles de IDs, estrategias como UNNEST con joins o paginación se vuelven necesarias.
Cursor y chunk: alternativas para datasets masivos
Cuando no es viable cargar toda la relación en memoria, Laravel proporciona cursor() y lazy() (Laravel 8+):
// Cursor: generador PHP, un modelo en memoria a la vez
foreach (Post::cursor() as $post) {
// Procesamiento individual
}
// Lazy: similar pero con eager loading posible mediante chunking interno
foreach (Post::lazy()->load('author') as $post) {
echo $post->author->name;
}
Para operaciones batch con relaciones, chunkWithRelations no existe nativamente, pero podés implementar:
Post::chunk(100, function ($posts) {
$posts->load('author');
foreach ($posts as $post) {
// Procesamiento con author disponible en memoria
}
});
Debugbar y Telescope: herramientas indispensables
En desarrollo, Laravel Debugbar muestra explícitamente queries duplicadas con indicadores de N+1. Telescope, por su parte, registra todas las queries en producción con sus tiempos de ejecución, permitiendo detectar degradaciones post-deployment.
Configurá alertas en Telescope para queries que excedan un threshold:
// config/telescope.php
'watchers' => [
Watchers\QueryWatcher::class => [
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
'slow' => 100, // ms
],
],
Conclusión
El problema N+1 en Laravel no es un defecto del framework sino una consecuencia de la abstracción que Eloquent proporciona. La solución sistemática requiere tres prácticas:
- Prevención en desarrollo:
Model::preventLazyLoading()para convertir problemas silenciosos en excepciones inmediatas - Eager loading consciente:
with()siempre que se acceda a relaciones en iteraciones, especificando columnas necesarias - Monitoreo continuo: Debugbar local, Telescope en staging/producción, y tests de assertion sobre conteo de queries
Con PostgreSQL como backend, complementá estas prácticas con análisis de planes de ejecución e índices apropiados. La combinación de un ORM productivo con una base de datos robusta y técnicas de profiling consistentes es lo que permite mantener aplicaciones Laravel performantes a escala.