← Volver al blog

Optimización de queries N+1 en Laravel con Eloquent

2026-05-31

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:

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:

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:

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:

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.