Eager loading and lazy loading in Laravel

Video thumbnail

Eager loading and lazy loading are two techniques available to retrieve related data when working with Eloquent models. And you need to know them in detail to use the techniques that best suit your needs; neither technique is better than the other, both are used to optimize application performance by reducing the number of database queries needed to get related data. Let's get to know them in detail.

When you work with Eloquent in Laravel, one of the keys to optimizing performance is understanding how relationships between models are loaded.

Two techniques dominate this area: lazy loading and eager loading. Neither is better than the other: everything depends on the context and how you want to balance efficiency and flexibility.

In this article, I explain their differences, how to apply them with real examples, and how to avoid the dreaded N+1 problem that can slow down your application without you realizing it.

⚙️ What is lazy loading in Laravel?

Also known as "on-demand loading" or "lazy loading"; this is the default behavior in Eloquent used when employing foreign key relationships.

The operation of this technique consists of Eloquent only retrieving data from the database at the moment you request it when obtaining a collection of related data (understood as a list of records coming from a relationship, for example, the list of posts given the category). That is, for each access to a related record, a separate query is executed to the database. This usually leads to the famous N+1 problem, where N+1 queries are executed to the database in the same task.

How lazy loading works

Imagine you are displaying a list of posts and, for each one, you need the name of its category:

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

Although it seems innocent, this code executes one query for each access to the relationship, generating the classic N+1 problem:
one main query + one for each related record.

The N+1 problem explained with real examples

In one of my projects, a posts dashboard, this behavior caused more than 15 queries for a single paginated page.
Performance plummeted.

To detect the origin, I enabled the following function in the AppServiceProvider:

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

Thanks to this, Laravel throws an exception when it tries to lazy load relationships in production.

The typical message is:

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

How to detect N+1 queries with DB::listen or Debugbar

If you want to audit your queries, you can use:

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

Or, if you prefer a visual interface, install Laravel Debugbar, ideal for local environments.

This way you'll know how many queries each view executes and you can act before load times skyrocket.

⚡ What is eager loading and when to use it

Video thumbnail

Eager loading or anxious loading allows retrieving all necessary relationships in a single query.

In this way, Laravel prepares the data from the beginning, avoiding the N+1 problem.

How to apply eager loading with the with() method

The most common way is using the `with()` method:

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

With this line, Eloquent loads the posts along with their categories in a single operation.

When accessing Eloquent relationships as properties, the relationship data is "deferred loaded" or what is known as lazy loading.

This means that the relationship data is not actually loaded until the property is first accessed.

However, Eloquent can "eager load" relationships at the moment it queries the primary model. Eager loading alleviates the N+1 queries problem.

To illustrate the N+1 queries problem, consider the following models:

<?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);
    }
}

The query for:

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

Will execute one query to retrieve all tutorials:

$tutorials = Tutorial->get();

Then, we iterate over all tutorials and, in turn, get the sections for each tutorial; if we have 25 tutorials, it would be one additional database query for each tutorial to get the sections; there would be 26 queries, 1 for the tutorial with the sections and 25 more for each section; here we have the problem known as N+1 since, depending on the number of tutorials we have, we have to make the same number of queries to the database to get their sections; but, things can get more complicated; if we now want to get the classes:

then, if we want to get the classes:

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

The result would be catastrophic; luckily, in Laravel we can solve this problem so that it only returns a single query to the database.

Eager Loading

In Laravel, Eager Loading is an optimization technique to reduce the number of database queries. By default, when retrieving data with relationships in Laravel, Lazy Loading is used, which can result in the N+1 query problem as explained previously.

Eager Loading is a data loading technique in which related data is loaded in advance to avoid the need to load it later when accessed in a single query, therefore, this technique solves the previous N+1 problem; for this, we must use the `with` function indicating the name of the relationship in the model:

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

Eager Loading Multiple Relationships

If we want to include additional relationships, we can specify them in an array; if the relationships are nested, we can do the following:

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

Just as you can see, we place the first relationship:

sections

And then the innermost relationship:

sections.classes

Eager Loading Conditions

Many times it is necessary to indicate additional conditions, for this, we can specify a callback function in which we place the internal conditions, in the following example, we want the classes to be posted or published:

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

And with this, you can easily create your relationships when loading the parent relationship; this is particularly useful when you need to build a Rest API and you initially need all the data, including the parent and child relationship.

Define eager loading directly in the models

In the models, we can predefine the use of eager loading as we saw before; for this, we use the `with` property:

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

The with method for loading Relationships with Eager Loading

When working with relationships in Eloquent, we often need to load all the relationships of a specific model or of several; we are talking about having a main model and one or more others related by a foreign key and we want to load all this data in a single query, of course, using Eloquent and avoiding the use of Joins for ease of the query; imagine you have an online store with products, categories, and tags. You want to get all products along with their related categories and tags. This is where Laravel's eager loading comes into play, since, through the `with` method, we can specify the relationships from the main query, obtaining the data completely organized and without repeated records as would happen if we used JOINs.

Eager loading allows loading all necessary relationships when performing the initial query. Instead of executing one query for each model in the collection, Eloquent performs a single additional query to load all relationships. This significantly improves the efficiency and speed of your application.

Suppose we have the following models: `Product`, `Category`, and `Tag`. We want to get all products along with their categories and tags:

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

And now, we can access ALL its relationships without the need to perform additional queries:

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";
}

In this way, we can optimize queries by avoiding problems like the N+1 in Laravel and thus have efficient queries; in this way, we can easily perform additional operations such as saving the data made in a single query to cache by using optimized SQL queries.

Practical example: categories and posts in Laravel

Instead of doing this:

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

We can optimize it like this:

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

However, be careful: if your posts contain large fields like `content`, you could overload the query.

In my case, I solved this by selecting only the necessary columns:

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

Nested eager loading and with conditions

Eloquent allows loading nested or filtered relationships easily:

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

This way, you avoid redundant queries even in multi-level structures.

Eager vs Lazy loading: comparison and performance

Technique    Queries generated    Performance    Recommended usage
Lazy loading    1 + N (one for each relationship)    Slower if there are many relationships    When you only access one or a few specific relationships
Eager loading    1 or few queries    Faster in large collections    When you need to display several relationships at the same time

The difference is abysmal when working with complex relationships.

Related methods: has(), with() and whereHas()

In Laravel, the model layer with Eloquent is one of the richest layers of the MVC that Laravel includes, and rightly so, since it is the data layer, which connects to the database to manage it; there are many methods available in Eloquent, but, let's look at 3 that can be confused: `has`, `with`, and `whereHas`.

1. "Has": Filtering models based on relationships

The `has()` method is used to filter the selected models based on a relationship. It works similarly to a normal `WHERE` condition but with a relationship. 

If you use `has('relation')`, it means you only want to get the models that have at least one related model in this relationship.

For example, let's consider a blog system with two tables: "posts" and "comments". If we want to get all users who have at least one comment, we can do it as follows:

$users = User::has('comments')->get(); 
// Only users who have at least one comment will be included in the collection

In short, it is a kind of conditional in which users who have at least one comment are obtained; in this example, comments is a relationship of the users.

2. "With": Loading relationships efficiently (eager loading)

The `with()` method is used to load relationships along with the main model, we previously saw the N+1 problem in Laravel which is solved using the `with` method.

Basically, along with the main model, Laravel will load the relationships you specify. This is especially useful when you have a collection of models and want to load a relationship for all of them.

$users = User::with('posts')->get();
foreach ($users as $user) {
  // The posts are already loaded and no additional query is executed
  $user->posts;
}

3. "WhereHas": Filtering based on relationships with additional conditions

The `whereHas()` method works similarly to `has()`, but allows you to specify additional filters for the related model. You can add custom conditions to check on the related model.

For example, if we want to get all users who have posts created after a specific date, we can do it like this:

$users = User::whereHas('posts', function ($query) {
    $query->where('created_at', '>=', '2026-01-01 00:00:00');
})->get();
// Only users who have posts from 2026 onwards will be included

What is whereHas?

Video thumbnail

In short, `has` (which I think is the first time I use it in a course) is the way to query relationships. It's that simple.

In a few words, we cannot query relationships using a `where`. Although there is a small consideration: if you were using a join, you could perfectly use `where`, which would be like, as they say, the traditional way all along.

But in this case we are working with `with`, and therefore, we cannot do it that way:

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);
                });
            })

This is the relationship with the models:

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');
    }
}

Summary: Why do we use whereHas?

In short, `whereHas` is the mechanism we have to make a `where` condition on a relationship.

Stick with that: that's why we use `whereHas`.
For everything else, there's Mastercard... sorry, there's Eloquent.

We already have this problem in our dashboard module, on the one hand, we have the main query:

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'));
}

And from the view, we reference the category, by default, Laravel uses the lazy loading technique to get the related data, therefore, every time a query is made, an additional query will be made, from the listing, we are getting the category and with this an additional query for each post on the page:

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

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

Which is the N+1 problem, where N in our example is the page size, about 10 representing the categories obtained from the post and the 1 is the main query to get the paginated data.

Luckily, in modern versions Laravel allows detecting this problem very easily through the following configuration:

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());
    }
}

With the AppServiceProvider we can load essential classes of our project to integrate them into the application.

So, if we now try to access the previous page, we will see an error on the screen like the following:

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

The N+1 problem detection system in Laravel is not perfect, since if we only had a 1-level pagination, the previous exception would not occur.

With an additional trick, we can see the queries made to resolve a client request:

routes/web.php

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

We can also use the Debugbar extension, but we will see that in the next chapter, if you enable the previous script, you will see that more than 15 queries occur, one of them for the authenticated user's session, permissions and roles, the one for the posts, and 10 for the categories if you have a 10-level pagination. This is great for detecting the problem but with the inconvenience that our category detail page no longer works, to fix it, we are going to introduce the next topic.

Let's create another example, we are going to use the posts relationship we have in the category:

app\Models\Category.php

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

If we get the relationship from the view:

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

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

We will see the previous exception, so, we get the posts along with the categories:

app\Http\Controllers\Dashboard\CategoryController.php

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

The problem with this scheme is that it will bring all the posts associated with a category, and a post is a somewhat heavy relationship, since it contains the content column with all the HTML content, and if we add the 10 categories in the listing, the problem multiplies.

There are several ways in which we can specify the columns we want to obtain from the secondary relationship:

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

Although these schemes are not supported by the posts relationship of the categories:

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

For now, we cannot solve the problem and with it the exception since for that we need to either change the request to use JOINs, or present the next topic which is Eager Loading which we will see next.

Eager Loading

With this process we can perform all operations in a single query, if we go back to the previous example, which has N+1 queries to the database, we will only perform a single query and with this, improve the performance of the application, for this, we must specify the relationship when performing the main query:

app\Http\Controllers\Dashboard\PostController.php

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

If we go to our category detail page, we will see that it works correctly.

This function has many implementations, for example, if we have a nested relationship:

class Tutorial extends Model
{
    ***
}

We can also define the use of this technique by default in the model:

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

The with() method can be extended in more complex relationships, like the following one that has a two-level relationship:

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);
    }
}

We can make queries in the following way, indicating more than one relationship to obtain:

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

Or if you want to place a condition on some of the relationships, you can implement a callback in the following way:

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

⚠️ Common errors and good practices in Eloquent

Avoid unnecessary queries

Use `with()` only when you really need to load relationships.

Don't abuse eager loading with heavy or little-used relationships.

Load only the necessary columns

You can limit the columns with:

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

This reduces the weight of each query.

Activate preventLazyLoading only in production

`Model::preventLazyLoading()` helps detect errors in development,
but only activate it in production if you are sure that your relationships are well optimized.

Conclusion

It is important to mention that there is no technique better than the other, since everything depends on what you want to do, but we can simplify it as follows: if you have a collection of related records using an FK as in the previous example and you are not going to use the foreign relationship, the technique you should use would be **lazy loading**, but, if you are going to use the collection with the related records, you must use **eager loading**.

Finally, for each relationship specified in **with()** it only adds one additional query, also remember to specify the columns as much as possible when obtaining the relationships.

There is no better technique than the other.

  • Use **lazy loading** when you don't need all the relationships.
  • Use **eager loading** when your view or API depends on multiple related data.

Laravel gives you the flexibility to combine both and obtain the best performance depending on the context.

❓ Frequently Asked Questions

  • Which is faster: eager or lazy loading?
    • **Eager loading**, because it groups the queries into a single SQL operation.
  • Can I combine both techniques?
    • Yes, Eloquent allows you to use eager loading for some relationships and lazy loading for others, depending on your needs.
  • How to solve the "lazy loading is disabled" error?
    • Temporarily activate the lazy loading mode in development or adjust your queries with `with()` to avoid the error.

The next step is the use of unit tests in Laravel.

I agree to receive announcements of interest about this Blog.

Eager loading and lazy loading are two techniques we have available to retrieve data related to working with Eloquent models. And we have to know them in detail to use techniques that best suit our needs.

| 👤 Andrés Cruz

🇪🇸 En español