Optimizar Consultas Eloquent en Laravel: Eager Loading vs Lazy Loading

- Andrés Cruz - EN In english

Video thumbnail

Índice de contenido

El eager loading y el lazy loading son dos técnicas disponibles en Eloquent para recuperar datos relacionados entre modelos. Conocerlas al detalle es fundamental para elegir la que mejor se ajuste a cada situación; no hay una técnica universalmente mejor que la otra. Ambas buscan optimizar el rendimiento de la aplicación reduciendo las consultas innecesarias a la base de datos. En este artículo las vemos en profundidad.

Este problema aparece con frecuencia al trabajar con relaciones de tipo muchos a muchos y relaciones polimórficas en Laravel.

Cuando trabajas con Eloquent en Laravel, una de las claves para optimizar el rendimiento es entender cómo se cargan las relaciones entre modelos.

Dos técnicas dominan este terreno: lazy loading (carga perezosa) y eager loading (carga ansiosa). Ninguna es mejor que la otra por defecto: todo depende del contexto y de cómo quieras balancear eficiencia y flexibilidad.

En este artículo te explico sus diferencias, cómo aplicarlas con ejemplos reales, y cómo evitar el temido problema N+1 que puede ralentizar tu aplicación sin que te des cuenta.

Optimizar las consultas a la base de datos en Laravel no es un "nice to have", es una necesidad real cuando una aplicación empieza a crecer. En proyectos pequeños todo parece funcionar bien, pero cuando entran más usuarios, listados grandes o APIs consumidas desde móviles, los problemas de rendimiento aparecen rápido.

⚙️ ¿Qué es lazy loading en Laravel?

También conocido como "carga bajo demanda" o "carga perezosa", este es el comportamiento predeterminado de Eloquent al trabajar con relaciones foráneas.

El funcionamiento es el siguiente: al momento de obtener una colección de datos (por ejemplo, el listado de publicaciones de una categoría), Eloquent solo recupera los datos relacionados en el momento en que los solicitas. Es decir, por cada acceso a un registro relacionado se ejecuta una consulta separada a la base de datos. Esto es lo que genera el famoso problema N+1, en donde se disparan N+1 consultas en una sola petición: una principal más una por cada registro relacionado.

Por qué es importante optimizar consultas en Laravel

Laravel facilita muchísimo el trabajo con bases de datos, pero esa facilidad puede jugar en contra si no prestamos atención a lo que realmente se ejecuta por debajo.

En desarrollo local muchas veces no notamos problemas porque todo es rápido. Sin embargo, cuando pasas a producción y tienes múltiples usuarios consultando listados al mismo tiempo, cada consulta innecesaria suma.

El impacto real en producción

Un listado mal optimizado puede ejecutar decenas o cientos de consultas SQL sin que lo notes a simple vista. Esto se traduce en:

  • Mayor carga en la base de datos.
  • Respuestas más lentas.
  • Peor experiencia de usuario.
  • Problemas de escalabilidad.

Relaciones entre modelos y consultas eficientes

Partimos de una relación entre Post y Category: los posts pertenecen a una categoría. Esta relación es el punto de partida para entender qué datos necesitamos cargar y qué debemos optimizar, sobre todo en los listados (por ejemplo, en el método index() del controlador).

Si en tu tabla de listado no estás utilizando la categoría, no hace falta traerla desde la base de datos. Por ejemplo:

<td>{{ $p->id }}</td>
<td>{{ $p->title }}</td>
<td>{{ $p->posted }}</td>

Si no accedes a $p->category->title, no necesitas cargar la relación. Evitar relaciones innecesarias es una de las optimizaciones más sencillas y con mayor impacto.

Esto es algo que aprendí rápido al trabajar con listados grandes: cada relación innecesaria es una consulta extra esperando suceder.

Ejemplo real: Book, Post y Category

En un caso real de este proyecto, la estructura de relaciones es la siguiente:

  • Book pertenece a Post
  • Post pertenece a Category

La categoría no se asigna directamente al Book, sino que llega a través del Post. Esto evita redundancia y mantiene la integridad de los datos. Además, en las relaciones se limitan los campos seleccionados para no traer columnas innecesarias:

class Book extends Model
{
    public function post()
    {
        return $this->belongsTo(Post::class)
            ->select(['id', 'url_clean', 'title', 'category_id']);
    }
}

class Post extends Model
{
    public function category()
    {
        return $this->belongsTo(Category::class)
            ->select(['id', 'url_clean', 'title']);
    }
}

Fíjate que en la relación post() del Book se incluye category_id en el select(). Esto es obligatorio: si Eloquent necesita resolver la relación anidada post.category, necesita esa clave foránea disponible en el resultado.

Referencia rápida para Eloquent

Esta pequeña hoja de trucos te será muy útil para consultar mientras escribes código:

1. Evitar Lazy Loading a nivel global

En tu AppServiceProvider, añade esto en el método boot() para detectar problemas N+1 durante el desarrollo:

Model::preventLazyLoading(!app()->isProduction());

2. Eager Loading básico

// Carga una relación
$posts = Post::with('category')->get();
// Carga múltiples relaciones
$posts = Post::with(['category', 'tags'])->get();

3. Eager Loading con condiciones (nested)

Cuando necesites filtrar o limitar lo que traes en la relación:

$tutorials = Tutorial::with(['sections.classes' => function ($query) {
   $query->where('posted', 'yes')->orderBy('orden');
}])->get();

Diferencias clave: has(), with() y whereHas()

Muchos se confunden con estos tres métodos. Aquí tienes la explicación directa:

  • with(): Carga datos relacionados. No filtra los resultados principales; solo trae los datos relacionados para evitar el problema N+1.
  • has(): Filtra. Solo devuelve los modelos principales que tienen al menos un registro en la relación (ej. User::has('posts') solo trae usuarios con al menos un post).
  • whereHas(): Filtra con condiciones adicionales sobre la relación (ej. User::whereHas('posts', fn($q) => $q->where('status', 'published'))).

Cómo funciona la carga perezosa (lazy loading)

Imagina que estás mostrando una lista de publicaciones y, por cada una, necesitas el nombre de su categoría:

$posts = Post::paginate(10);
@foreach ($posts as $p)
  {{ $p->category->title }}
@endforeach

Aunque parezca inocente, este código ejecuta una consulta adicional por cada iteración del bucle, generando el clásico problema N+1: una consulta principal para obtener los posts, más una consulta adicional por cada post para obtener su categoría.

El problema N+1 explicado con ejemplos reales

En uno de mis proyectos —un dashboard de publicaciones— este comportamiento causaba más de 15 consultas para una sola página paginada. El rendimiento se desplomaba bajo carga real.

Para detectar el origen, habilité en el AppServiceProvider la función:

Model::preventLazyLoading(app()->isProduction());

Gracias a esto, Laravel lanza una excepción cuando intenta cargar relaciones de forma perezosa. El mensaje típico que verás es:

Attempted to lazy load [category] on model [App\Models\Post] but lazy loading is disabled.

Cómo detectar consultas N+1 con DB::listen() o Debugbar

Si quieres auditar en tiempo real las consultas que se están ejecutando, puedes usar DB::listen() directamente en tu archivo de rutas o en el AppServiceProvider:

DB::listen(function ($query) {
  echo $query->sql;
});

O, si prefieres una interfaz visual más cómoda, instala Laravel Debugbar: es ideal para entornos locales y te muestra en tiempo real cuántas consultas se ejecutan por cada petición.

Así sabrás exactamente cuántas consultas genera cada vista y podrás actuar antes de que los tiempos de carga se disparen en producción.

⚡ Qué es eager loading y cuándo usarlo

Video thumbnail

El eager loading o carga ansiosa permite recuperar todas las relaciones necesarias en una sola consulta adicional, en lugar de ejecutar una por cada registro.

De esta forma, Laravel prepara todos los datos relacionados desde el principio, eliminando el problema N+1 de raíz.

Cómo aplicar eager loading con el método with()

La forma más común es pasando el nombre de la relación al método with():

$posts = Post::with('category')->paginate(10);

Con esta línea, Eloquent carga los posts junto con sus categorías en dos consultas: una para los posts y una para todas las categorías relacionadas. Sin with(), se ejecutaría una consulta extra por cada post en el listado.

Al acceder a las relaciones de Eloquent como propiedades (sin with()), los datos se cargan de forma diferida o lazy: los datos de la relación no se recuperan realmente hasta que se accede por primera vez a la propiedad.

Esto significa que los datos de la relación no se cargan realmente hasta que se accede por primera vez a la propiedad.

Sin embargo, Eloquent puede cargar ansiosamente (eagerly) las relaciones en el momento en que consulta el modelo principal. La carga ansiosa es precisamente la solución al problema de las consultas N+1.

Para ilustrar el problema, considera los siguientes modelos:

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\URL;
class Tutorial extends Model
{
    use HasFactory;
    protected $fillable = [***];
    public function sections()
    {
        return $this->hasMany(TutorialSection::class)->orderBy('orden');
    }
}
class TutorialSection extends Model
{
    use HasFactory;
    protected $fillable = [***];
    public function tutorial()
    {
        return $this->belongsTo(Tutorial::class);
    }
    public function classes()
    {
        return $this->hasMany(TutorialSectionClass::class)->orderBy('orden');
    }
}
class TutorialSectionClass extends Model
{
    use HasFactory;
    protected $fillable = [***];
    public function tutorialSection()
    {
        return $this->belongsTo(TutorialSection::class);
    }
    public function comments()
    {
        return $this->hasMany(TutorialSectionClassComment::class);
    }
}

La siguiente consulta con lazy loading:

$tutorials = Tutorial->get();
foreach ($tutorials as $key => $t)
  foreach ($t->sections as $key => $s)

Ejecutará una consulta para recuperar todos los tutoriales y, luego, una consulta adicional por cada tutorial para obtener sus secciones. Si tienes 25 tutoriales, el resultado son 26 consultas (1 + 25). Aquí ya tienes el problema N+1. Pero la cosa puede complicarse aún más si además quieres obtener las clases de cada sección:

foreach ($s->classes->get() as $k => $c)

El resultado sería catastrófico en términos de rendimiento. Por suerte, Laravel nos permite solucionar esto haciendo una única consulta agrupada a la base de datos mediante eager loading.

Eager Loading

En Laravel, el Eager Loading es la técnica de optimización que reduce el número de consultas a la base de datos. Por defecto, cuando se recuperan datos con relaciones en Laravel, se utiliza el Lazy Loading, lo que puede resultar en el problema de la consulta N+1 como se explicó anteriormente.

El Eager Loading carga los datos relacionados de manera anticipada en una única operación, evitando la necesidad de disparar consultas adicionales cuando se accede a cada relación. Para ello, se usa el método with() indicando el nombre de la relación definida en el modelo:

$tutorial = Tutorial::with('sections');

Eager Loading con múltiples relaciones

Si quieres cargar varias relaciones al mismo tiempo, incluyendo relaciones anidadas, puedes especificarlas en un array usando la notación de punto para los niveles:

$tutorial = Tutorial::with(['sections','sections.classes' ])

Aquí, sections carga la primera relación y sections.classes carga la relación anidada dentro de cada sección.

Eager Loading con condiciones

Muchas veces necesitas aplicar condiciones adicionales a lo que traes en la relación. Para eso puedes pasar un callback con las restricciones internas. En el siguiente ejemplo, cargamos solo las clases que estén publicadas y en el orden correcto:

$tutorial = Tutorial::with('sections')->with(['sections.classes' => function ($query) {
           $query->where('posted', 'yes');
       }])->find($tutorial->id);

Con esto puedes construir consultas muy precisas al momento de cargar la relación padre e hijos, algo especialmente útil cuando necesitas exponer datos en una API REST.

Definir el eager loading directamente en los modelos

Si siempre necesitas que una relación se cargue junto al modelo, puedes definirla de forma predeterminada usando la propiedad $with:

class Tutorial extends Model
{
    ***
    protected $with = ['sections'];
}

Con esta configuración, cada vez que consultes un Tutorial, sus secciones se cargarán automáticamente sin necesidad de añadir with('sections') en cada consulta. Úsalo con precaución: si no siempre necesitas esa relación, es mejor cargarlo de forma explícita para evitar consultas innecesarias.

Método with() para cargar relaciones con Eager Loading

Cuando trabajamos con relaciones en Eloquent, a menudo necesitamos cargar todas las relaciones de uno o varios modelos en una sola consulta, evitando el uso de JOINs y simplificando el código.

Imagina que tienes una tienda en línea con productos, categorías y etiquetas. Quieres obtener todos los productos junto con sus categorías y etiquetas relacionadas.

Aquí es donde entra en juego el eager loading de Laravel: mediante el método with() puedes especificar las relaciones desde la consulta principal, obteniendo los datos completamente organizados y sin registros duplicados, algo que sí puede ocurrir con los JOIN.

La carga ansiosa permite cargar todas las relaciones necesarias al realizar la consulta inicial. En lugar de ejecutar una consulta por cada modelo en la colección, Eloquent realiza una sola consulta adicional para cargar todas las relaciones de golpe. Esto mejora significativamente la eficiencia y la velocidad de tu aplicación.

Supongamos que tenemos los siguientes modelos: Product, Category y Tag. Queremos obtener todos los productos junto con sus categorías y etiquetas:

$products = Product::with(['category', 'tags'])->get();

Y ahora podemos acceder a todas sus relaciones sin necesidad de realizar consultas adicionales:

foreach ($products as $product) {
    echo "Product: {$product->name}\n";
    echo "Category: {$product->category->name}\n";
    foreach ($product->tags as $tag) {
        echo "Tag: {$tag->name}\n";
    }
    echo "\n";
}

De esta forma optimizamos las consultas evitando el problema N+1 en Laravel. Además, al tener los datos en una sola consulta agrupada, es mucho más sencillo almacenarlos en caché si lo necesitas.

Ejemplo práctico: categorías y publicaciones en Laravel

En lugar de hacer esto (que dispara N+1 consultas):

$categories = Category::paginate(10);
@foreach ($categories as $c)
  {{ $c->posts }}
@endforeach

Puedes optimizarlo así:

$categories = Category::with('posts')->paginate(10);

Sin embargo, hay que tener cuidado: si tus posts contienen campos grandes como content, puedes sobrecargar la consulta trayendo datos que no necesitas. La solución es seleccionar solo las columnas necesarias:

$posts = Post::with('category:id,title')->paginate(10);

Eager loading anidado y con condiciones

Eloquent también permite cargar relaciones anidadas o filtradas de forma muy limpia:

Tutorial::with('sections')
   ->with(['sections.classes' => function ($query) {
       $query->where('posted', 'yes')->orderBy('orden');
   }])
   ->find($tutorial->id);

Así evitas consultas redundantes incluso en estructuras de varios niveles de profundidad.

Eager vs Lazy loading: comparación y rendimiento

Video thumbnail
TécnicaConsultas generadasRendimientoUso recomendado
Lazy Loading (Carga diferida)1 + N (una por cada relación accedida)Más lento si hay muchas relacionesCuando solo se accede a una o pocas relaciones específicas
Eager Loading (Carga ansiosa)1 o pocas consultas agrupadasMás rápido en colecciones grandesCuando se necesitan mostrar varias relaciones al mismo tiempo

La diferencia en rendimiento es abismal cuando se trabaja con relaciones complejas o listados con muchos registros.

Métodos relacionados: has(), with() y whereHas()

En Laravel, la capa de modelos con Eloquent es una de las más ricas y completas del patrón MVC. Hay muchos métodos disponibles, pero hay tres que generan confusión con frecuencia: has(), with() y whereHas(). Los vemos uno a uno.

1. has(): Filtrando modelos basados en relaciones

El método has() se utiliza para filtrar los modelos seleccionados basándose en la existencia de una relación. Funciona de manera similar a una condición normal WHERE, pero aplicada sobre una relación.

Si usas has('relation'), significa que solo deseas obtener los modelos que tienen al menos un modelo relacionado en esa relación.

Por ejemplo, si queremos obtener todos los usuarios que tienen al menos un comentario:

$users = User::has('comments')->get();
// Solo se incluirán los usuarios que tienen al menos un comentario en la colección

En pocas palabras, has() actúa como un condicional de existencia: solo devuelve los registros del modelo principal que tienen al menos un registro asociado en la relación especificada.

2. with(): Cargando relaciones de manera eficiente (eager loading)

El método with() se utiliza para cargar relaciones junto con la consulta principal. Como vimos anteriormente, es la solución directa al problema N+1 en Laravel.

Laravel cargará las relaciones que especifiques de forma agrupada. Esto es especialmente útil cuando tienes una colección de modelos y deseas cargar una relación para todos ellos sin multiplicar las consultas:

$users = User::with('posts')->get();
foreach ($users as $user) {
    // Las publicaciones ya están cargadas y no se ejecuta una consulta adicional
    $user->posts;
}

3. whereHas(): Filtrando basado en relaciones con condiciones adicionales

El método whereHas() funciona de manera similar a has(), pero te permite especificar condiciones adicionales sobre el modelo relacionado. Es la forma de hacer un WHERE sobre una relación en Eloquent.

Por ejemplo, si queremos obtener todos los usuarios que tienen publicaciones creadas después de una fecha específica:

$users = User::whereHas('posts', function ($query) {
    $query->where('created_at', '>=', '2026-01-01 00:00:00');
})->get();
// Solo se incluirán los usuarios que tienen publicaciones desde 2026 en adelante

¿Qué es whereHas()?

Video thumbnail

En resumidas cuentas, whereHas() es la forma de hacer consultas con condiciones sobre relaciones en Eloquent. Así de simple.

Mediante un where convencional no puedes filtrar directamente sobre relaciones. Aunque si estuvieras empleando un join, perfectamente podrías usar where, que sería la manera tradicional de siempre.

Pero cuando trabajas con with(), necesitas whereHas() para aplicar condiciones sobre la relación cargada:

Book::with(['post', 'post.category'])
         ->when($this->category_id, function (Builder $query, $category_id) {
                $query->whereHas('post', function ($q) use ($category_id) {
                    $q->where('category_id', $category_id);
                });
            })

Esta es la relación entre los modelos de ese ejemplo:

class Book extends Model
{
    ***
    public function post()
    {
        return $this->belongsTo(Post::class);
    }
}
class Post extends TaggableModel
{
    ***
    public function category()
    {
        return $this->belongsTo(Category::class);
        //->select('id', 'title', 'slug');
    }
}

Resumen: ¿Para qué usamos whereHas()?

whereHas() es el mecanismo que tenemos para aplicar un WHERE condicional sobre una relación en Eloquent.

Quédate con eso: para eso usamos whereHas().
Para todo lo demás, existe Mastercard… perdón, existe Eloquent.

En el módulo de dashboard de este proyecto tenemos este caso. Por un lado, la consulta principal en el controlador:

app\Http\Controllers\Dashboard\PostController.php

public function index()
{
    if(!auth()->user()->hasPermissionTo('editor.post.index')){
        return abort(403);
    }
    $posts = Post::paginate(10);
    return view('dashboard/post/index', compact('posts'));
}

Y desde la vista, referenciamos la categoría. Por defecto, Laravel emplea lazy loading para obtener los datos relacionados, por lo que cada iteración genera una consulta adicional:

resources\views\dashboard\post\index.blade.php

@foreach ($posts as $p)
     ****
     <td>
        {{ $p->category->title }}
***

Esto es el problema N+1 en acción: N es el tamaño de la página (10 posts = 10 consultas adicionales para las categorías) y el +1 es la consulta principal paginada.

Por suerte, Laravel permite detectar este problema fácilmente mediante la siguiente configuración en el AppServiceProvider:

app\Providers\AppServiceProvider.php

<?php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
***
class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Model::preventLazyLoading(app()->isProduction());
    }
}

Con el AppServiceProvider podemos registrar configuraciones esenciales que se cargan al arrancar la aplicación.

Si ahora intentamos acceder a la página anterior, veremos un error como el siguiente:

Attempted to lazy load [category] on model [App\Models\Post] but lazy loading is disabled.

El sistema de detección del problema N+1 en Laravel no es perfecto: si tuvieras una paginación de un solo registro, la excepción no se dispararía. Aun así, es una herramienta muy útil para detectar estos problemas durante el desarrollo.

Como truco adicional, puedes registrar un listener para ver todas las consultas que se ejecutan en cada petición:

routes/web.php

DB::listen(function ($query){
    echo $query->sql;
  //  Log::info($query->sql, ['bindings' => $query->bindings, 'time' => $query->time]);
});

Si habilitas este bloque, verás que ocurren más de 15 consultas: una para la sesión del usuario autenticado, los permisos y roles, la de los posts y 10 para las categorías (si la paginación es de 10 registros). Esto es estupendo para detectar el problema, pero tiene el inconveniente de que nuestra página de detalle de categoría deja de funcionar mientras está activo. Para corregirlo, presentamos el siguiente tema.

Vamos a crear otro ejemplo usando la relación de posts que tenemos en el modelo de categoría:

app\Models\Category.php

class Category extends Model
{
   ***
    function posts() {
        return $this->hasMany(Post::class);
    }
}

Si desde la vista obtenemos la relación:

resources\views\dashboard\category\index.blade.php

@foreach ($categories as $c)
    ***
        <td>
            {{ $c->posts }}

Veremos la excepción anterior. La solución es cargar los posts junto con las categorías desde el controlador:

app\Http\Controllers\Dashboard\CategoryController.php

$categories = Category::with('posts')->paginate(10);

El problema de este enfoque es que va a traer todos los posts asociados a cada categoría, y el modelo Post tiene una columna content con todo el HTML del artículo. Multiplicado por las 10 categorías del listado, el problema se agrava considerablemente.

Existen varias formas de limitar las columnas que queremos obtener de la relación secundaria:

$posts = Post::with('category:id,title')->paginate(10);
$posts = Post::with(['category' => function($query){
   // $query->where('id',1);
   $query->select('id','title');
}])->paginate(10);

Aunque estos esquemas no funcionan directamente cuando es la categoría quien carga los posts mediante hasMany:

$categories = Category::with('posts:id,title')->paginate(10);
$categories = Category::with(['posts' => function($query){
    // $query->where('id',1);
    $query->select('id','title');
}])->paginate(10);

En este caso, la solución es aplicar eager loading correctamente en la consulta principal, tal como veremos a continuación.

Eager Loading (Carga ansiosa)

Con eager loading podemos resolver el problema del N+1 realizando todas las operaciones en el mínimo número de consultas posibles. En lugar de ejecutar una consulta por cada registro relacionado, solo realizaremos una consulta adicional agrupada, mejorando el rendimiento de la aplicación de forma drástica. Para ello, especificamos la relación al momento de realizar la consulta principal:

app\Http\Controllers\Dashboard\PostController.php

$posts = Post::with(['category'])->paginate(10);

Si vamos ahora a la página de listado de categorías, veremos que funciona correctamente y sin disparar consultas extra.

Esta técnica tiene muchas variantes. Por ejemplo, también podemos definir el eager loading por defecto directamente en el modelo:

class Post extends Model
{
    protected $with = ['category'];
}

Y el método with() se puede extender para manejar relaciones de dos o más niveles:

class Tutorial extends Model
{
  ***
    public function sections()
    {
        return $this->hasMany(Tutorial::class);
    }
}
class TutorialSection extends Model
{
  ***
    public function tutorial()
    {
        return $this->belongsTo(Tutorial::class);
    }
    public function classes()
    {
        return $this->hasMany(Tutorial::class);
    }
}
class TutorialSectionClass extends Model
{
    ***
    public function tutorialSection()
    {
        return $this->belongsTo(TutorialSection::class);
    }
}

Podemos hacer consultas con múltiples relaciones en una sola llamada:

$posts = Post::with(['categories','tags'])->get();

O aplicar condiciones sobre alguna de las relaciones mediante un callback:

Tutorial::with('sections')->with(['sections.classes' => function ($query) {
     $query->where('posted', 'yes');
     $query->orderBy('orden');
    }])->where('posted', 'yes')->find($tutorial->id);
}

Prevenir el Lazy Loading en Laravel: 3 formas

En esta sección veremos cómo podemos detectar y prevenir el Lazy Loading en Laravel. Partiremos del siguiente código de controlador que ya conocemos:

app\Http\Controllers\Dashboard\PostController.php

public function index()
{
    $posts = Post::paginate(10);
    return view('dashboard/post/index', compact('posts'));
}

Donde un Post tiene una relación foránea con las categorías:

class Post extends Model
{
    use HasFactory;
    protected $fillable = ['title', 'slug', 'content', 'category_id', 'description', 'posted', 'image'];
    public function category()
    {
        return $this->belongsTo(Category::class);
    }
}

Desde la vista, referenciamos la categoría, y por defecto Laravel emplea lazy loading para obtener los datos relacionados, generando una consulta adicional por cada post del listado:

resources\views\dashboard\post\index.blade.php

@foreach ($posts as $p)
     ****
     <td>
        {{ $p->category->title }}
***

Esto es el problema N+1: N es el tamaño de la página (10 categorías cargadas desde los posts) y el +1 es la consulta principal paginada.

1. Mediante el AppServiceProvider

Laravel permite detectar este problema fácilmente mediante la siguiente configuración:

app\Providers\AppServiceProvider.php

<?php
namespace App\Providers;
use Illuminate\Database\Eloquent\Model;
***
class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Model::preventLazyLoading(app()->isProduction());
    }
}

El AppServiceProvider es el lugar ideal para este tipo de configuraciones globales que afectan al comportamiento de Eloquent en toda la aplicación.

Si ahora intentamos acceder al listado, veremos el siguiente error en pantalla:

Attempted to lazy load [category] on model [App\Models\Post] but lazy loading is disabled.

El sistema de detección no es perfecto: si la paginación fuera de un solo registro, la excepción no se dispararía. Aun así, es la forma más rápida y directa de detectar el problema.

2. Viendo las consultas SQL con DB::listen()

Otra forma de detectar el problema N+1 es registrar un listener para ver en tiempo real las consultas que se ejecutan por cada petición:

routes/web.php

DB::listen(function ($query){
    echo $query->sql;
  //  Log::info($query->sql, ['bindings' => $query->bindings, 'time' => $query->time]);
});

Desde el navegador, verías imprimirse una consulta SQL cada vez que se referencia la categoría desde el post. Es rápido para depurar, pero no es algo que quieras dejar en producción.

3. Usando Laravel Debugbar

La tercera opción es instalar Laravel Debugbar, una extensión que se muestra como una barra en la parte inferior del navegador. Si la activas con el listener anterior, verás que ocurren más de 15 consultas por petición: una para la sesión del usuario autenticado, los permisos y roles, la de los posts, y 10 adicionales para las categorías (con paginación de 10 registros). Es ideal para detectar el problema con una interfaz visual clara y sin ensuciar el HTML.

⚠️ Errores comunes y buenas prácticas en Eloquent

Evitar consultas innecesarias

Usa with() solo cuando realmente necesites cargar relaciones en tu vista o lógica. No abuses del eager loading con relaciones pesadas o poco utilizadas: cargar más datos de los necesarios también es una forma de degradar el rendimiento.

Cargar solo las columnas necesarias

Puedes limitar las columnas que traes en cada relación usando la notación de dos puntos:

Post::with('category:id,title');

Así reduces el peso de cada consulta considerablemente, sobre todo cuando los modelos tienen campos grandes como content o body.

Activar preventLazyLoading() correctamente

Model::preventLazyLoading() ayuda a detectar errores de N+1 en desarrollo. La forma recomendada es habilitarlo siempre que no estés en producción:

Model::preventLazyLoading(!app()->isProduction());

De esta forma, en local y staging te avisa de los problemas, pero en producción no interrumpe el flujo de la aplicación.

Conclusión

No hay una técnica mejor que la otra. Todo depende del contexto y de los datos que necesites en cada operación. Dicho esto, podemos simplificarlo así: si no vas a usar la relación foránea, emplea la carga perezosa (o simplemente no la cargues). Si vas a utilizar los datos relacionados en el listado o en la respuesta, emplea la carga ansiosa para evitar el problema N+1.

Recuerda también que por cada relación especificada en el with() se suma solo una consulta adicional. Y siempre que sea posible, especifica las columnas que necesitas en lugar de traer el modelo completo.

No existe una técnica mejor que la otra.

  • Usa lazy loading cuando no necesites todas las relaciones.
  • Usa eager loading cuando tu vista o API dependa de múltiples datos relacionados.

Laravel te da la flexibilidad de combinar ambas técnicas y obtener el mejor rendimiento según el contexto.

Eloquent vs Query Builder

Laravel nos ofrece dos caminos claros para optimizar consultas: Eloquent y el Query Builder. Ninguno es mejor por defecto; todo depende del contexto y de la complejidad de la consulta.

  • Eloquent con with(): trae las relaciones definidas evitando el problema de N+1 queries. Ideal para la mayoría de casos.
  • Query Builder con join o leftJoin: también permite optimización mediante joins directos. La sintaxis es diferente pero ofrece más control sobre el SQL generado.

Cuándo conviene usar join o leftJoin

En algunos casos, especialmente en listados complejos o en APIs donde necesito máximo control sobre los datos que entran en la consulta, prefiero usar leftJoin directamente:

Book::select(
   'books.title',
   'books.subtitle',
   'books.date',
   'books.posted',
   'file_payments.payments'
)
->leftJoin('file_payments', function ($join) use ($user) {
   $join->on('books.id', 'file_payments.file_paymentable_id')
        ->where('file_paymentable_type', Book::class)
        ->where('file_payments.user_id', $user->id);
})
->where('posted', 'yes')
->get();

Aquí controlo exactamente qué datos entran en la consulta y evito cargar modelos completos que no necesito.

Seleccionar solo las columnas necesarias

Uno de los errores más comunes es usar SELECT * en listados. Campos como content, body o textos largos no deberían cargarse si no se usan en esa vista.

Evitar SELECT * en listados

$books = Book::select(
   'title',
   'subtitle',
   'date',
   'url_clean',
   'posted',
   'price'
)->get();

Esto reduce:

  • Tamaño de la respuesta.
  • Uso de memoria.
  • Tiempo de ejecución.

Optimización de consultas en APIs REST

En dashboards administrativos con muchos registros, limitar columnas ya marca una diferencia clara. Y en APIs, esto es todavía más importante.

Una API debería:

  • Enviar solo lo que el cliente necesita.
  • Evitar campos pesados innecesarios.
  • Reducir la cantidad de consultas.

Si un endpoint es para listados, no tiene sentido devolver el contenido completo de cada recurso. El campo content solo debería cargarse en la vista de detalle.

Rendimiento en dispositivos móviles

En móviles, cada byte cuenta:

  • Menos datos → menos tiempo de carga.
  • Menos consultas → mejor batería y experiencia.
  • Respuestas más rápidas → apps más fluidas.

Herramientas para detectar consultas lentas

Antes de optimizar, hay que ver qué está pasando realmente. Estas son las herramientas que uso habitualmente:

  • Laravel Telescope: permite ver consultas ejecutadas, duplicados y tiempo de ejecución. Es ideal para detectar N+1 rápidamente en desarrollo.
  • Laravel Debugbar: muestra consultas directamente en la vista del navegador. Muy cómodo para el día a día.
  • Clockwork: funciona como extensión del navegador, similar a Debugbar pero con una interfaz más limpia.

En más de una ocasión, gracias a estas herramientas, descubrí consultas innecesarias que no eran evidentes a simple vista.

Buenas prácticas finales para optimizar Eloquent

  • Checklist rápida de optimización:
    • Usa with() para cargar relaciones que vayas a usar.
    • Evita cargar relaciones que no usas en esa vista o endpoint.
    • Selecciona solo las columnas necesarias con select().
    • Usa join o leftJoin cuando necesites máximo control.
    • Optimiza siempre listados y APIs.
    • Revisa las consultas con herramientas de debug antes de pasar a producción.

Recomendaciones de optimización

1. Usar with() o join

Siempre que trabajes con relaciones y listados, trae solo los datos necesarios desde la base de datos. Por ejemplo, especificando las columnas directamente en el with():

Post::with('category:id,url_clean,title');

O limitando los campos directamente en la definición de la relación en el modelo:

public function category()
{
   return $this->belongsTo(Category::class)
       ->select(['id', 'url_clean', 'title']);
}

2. Seleccionar únicamente las columnas necesarias

No traigas campos grandes como content si no los vas a usar en el listado:

$books = Book::select(
   'books.title', 'books.subtitle', 'books.date', 
   'books.url_clean', 'books.description', 'books.image', 
   'books.path', 'books.page', 'books.posted', 
   'books.price', 'books.price_offers', 'books.post_id'
)->get();

Esto reduce la cantidad de datos transferidos entre la base de datos y PHP, y mejora el rendimiento de forma inmediata.

3. Evitar errores con select() y relaciones

Estas recomendaciones también aplican para REST APIs, donde es crítico:

  • Traer solo los datos que el cliente necesita.
  • Evitar cargar contenido pesado innecesariamente.
  • Reducir el número de consultas para mejorar la velocidad y el consumo de recursos.

Ejemplo con leftJoin en una API:

Book::select(
   'books.title', 'books.subtitle', 'books.date', 'books.url_clean', 
   'books.description', 'books.image', 'books.path', 'books.page', 
   'books.posted', 'books.price', 'books.price_offers', 'books.post_id',
   DB::raw('DATE_FORMAT(file_payments.created_at, "%d-%m-%Y %H:%i") as date_buyed'),
   'file_payments.payments'
)
->leftJoin('file_payments', function ($leftJoin) use ($user) {
   $leftJoin
       ->on('books.id', 'file_payments.file_paymentable_id')
       ->where("file_paymentable_type", Book::class)
       ->where('file_payments.user_id', $user->id)
       ->where('file_payments.unenroll');
})
->where('posted', 'yes')
->get();

REST API y optimización de consultas

Si no puedes limitar los campos directamente desde la consulta, puedes hacerlo desde la definición de la relación en el modelo, tal como mostré antes. Todo lo que mencionamos sobre optimización de consultas aplica igualmente para una REST API, y cobra especial importancia cuando manejamos listados de datos.

Por ejemplo, una consulta optimizada a los libros para una API podría ser:

$books = Book::select(
    'books.title',
    'books.subtitle',
    'books.date',
    'books.url_clean',
    'books.description',
    'books.image',
    'books.path',
    'books.page',
    'books.posted',
    'books.price',
    'books.price_offers',
    'books.post_id',
    DB::raw('DATE_FORMAT(file_payments.created_at, "%d-%m-%Y %H:%i") as date_buyed'),
    'file_payments.payments'
)->leftJoin('file_payments', function ($leftJoin) use ($user) {
    $leftJoin
        ->on('books.id', 'file_payments.file_paymentable_id')
        ->where("file_paymentable_type", Book::class)
        ->where('file_payments.user_id', $user->id)
        ->where('file_payments.unenroll');
})->where('posted', 'yes')
->get();

En este caso uso leftJoin para traer datos adicionales de otra tabla y especifico con select() únicamente los campos que realmente necesito. No utilicé with() porque estoy manejando los joins directamente, y la idea es traer solo los datos para el listado sin incluir campos pesados como content.

Diferencias y consideraciones

  • Cuando hacemos un listado en el dashboard o para una REST API, no necesitamos traer campos de contenido completo (content) que solo se usarían en el detalle. Esto reduce la carga en la base de datos y optimiza la respuesta de la API.
  • Si necesitamos mostrar el contenido completo, solo entonces incluimos el campo content, por ejemplo, en la vista de detalle del libro.
  • En dispositivos móviles, cargar menos datos es crucial porque reduce el peso de la página y mejora la experiencia del usuario.

❓ Preguntas frecuentes

  • ¿Cuál es más rápido: eager o lazy loading?
    • Eager loading, porque agrupa las consultas en una sola operación SQL en lugar de ejecutarlas de forma individual por cada registro.
  • ¿Puedo combinar ambas técnicas?
    • Sí, Eloquent permite usar eager loading en algunas relaciones y lazy en otras, según la necesidad de cada operación.
  • ¿Cómo solucionar el error de "lazy loading is disabled"?
    • La solución correcta es ajustar tus consultas añadiendo with() para las relaciones que uses. Alternativamente, puedes deshabilitar temporalmente preventLazyLoading() durante el desarrollo, pero no es la solución a largo plazo.
  • ¿Es mejor usar Eloquent o Query Builder?
    • Depende del caso. Para relaciones entre modelos, Eloquent con with() es ideal. Para consultas complejas con joins múltiples o cuando necesitas máximo control del SQL, el Query Builder puede ser mejor.
  • ¿Siempre debo usar with()?
    • Solo cuando realmente vas a usar esa relación en la vista o en la respuesta. Cargar relaciones innecesarias también es un error que penaliza el rendimiento.
  • ¿Dónde se nota más el problema N+1?
    • En listados grandes y en producción, especialmente con muchos usuarios concurrentes o paginaciones con muchos registros por página.
  • ¿Esto aplica también a APIs?
    • Sí, incluso más. Las APIs deben ser ligeras y eficientes, y el problema N+1 tiene un impacto directo en los tiempos de respuesta y el consumo de recursos del servidor.

Conclusión

Optimizar consultas con Eloquent en Laravel no es complicado, pero sí requiere disciplina y atención. Especialmente en listados y APIs, pequeñas decisiones marcan una gran diferencia en producción.

En mi experiencia, entender bien las relaciones, evitar redundancias y traer solo los datos necesarios mejora el rendimiento, la escalabilidad y la organización del proyecto.

  • Optimiza siempre tus consultas, especialmente en listados y APIs.
  • Usa with() o join para reducir el problema N+1.
  • Trae únicamente las columnas que vas a usar.
  • Evita redundancia en tus modelos y relaciones.

Y una vez que tienes esto claro, el siguiente paso natural es aprender a depurar: herramientas como Debugbar, Telescope o Clockwork se vuelven indispensables para mantener tus consultas bajo control.

El siguiente paso es el uso de las pruebas unitarias en Laravel.

¿Tu app en Laravel va lenta? Conoce a fondo Eager Loading y Lazy Loading en Eloquent, detecta consultas N+1 y optimiza el rendimiento de tus bases de datos fácilmente.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.