Optimizing Eloquent Queries in Laravel: Eager Loading vs Lazy Loading

- Andrés Cruz - ES En español

Video thumbnail

Content Index

Eager loading and lazy loading are two techniques available in Eloquent to retrieve related data between models. Understanding them in detail is fundamental to choosing the one that best suits each situation; there is no universally better technique over the other. Both seek to optimize application performance by reducing unnecessary database queries. In this article, we look at them in depth.

This issue frequently arises when working with many-to-many relationships and polymorphic relationships in Laravel.

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

Two techniques dominate this space: lazy loading and eager loading. Neither is better than the other by default: 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-world examples, and how to avoid the dreaded N+1 problem that can slow down your application without you realizing it.

Optimizing database queries in Laravel is not a "nice to have"; it is a real necessity when an application starts to grow. In small projects everything seems to work fine, but as more users, large lists, or APIs consumed from mobile devices come into play, performance issues appear quickly.

⚙️ What is lazy loading in Laravel?

Also known as "on-demand loading" or "deferred loading," this is Eloquent's default behavior when working with foreign relationships.

It works as follows: when obtaining a collection of data (for example, the list of posts in a category), Eloquent only retrieves the related data at the moment you request it. That is, for each access to a related record, a separate query is executed against the database. This is what generates the famous N+1 problem, where N+1 queries are triggered in a single request: one primary query plus one for each related record.

Why optimizing queries in Laravel is important

Laravel makes working with databases extremely easy, but that ease can work against you if you do not pay attention to what actually runs underneath.

In local development, we often do not notice problems because everything is fast. However, when you move to production and have multiple users querying lists simultaneously, every unnecessary query adds up.

The real impact in production

A poorly optimized listing can execute dozens or hundreds of SQL queries without you noticing at first glance. This translates into:

  • Higher load on the database.
  • Slower response times.
  • Poorer user experience.
  • Scalability issues.

Model relationships and efficient queries

We start from a relationship between Post and Category: posts belong to a category. This relationship is the starting point for understanding what data we need to load and what we must optimize, especially in listings (for instance, in the controller's index() method).

If you are not using the category in your table listing, there is no need to fetch it from the database. For example:

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

If you do not access $p->category->title, you do not need to load the relationship. Avoiding unnecessary relationships is one of the simplest optimizations with the highest impact.

This is something I learned quickly when working with large listings: every unnecessary relationship is an extra query waiting to happen.

Real-world example: Book, Post, and Category

In a real case from this project, the relationship structure is as follows:

  • Book belongs to Post
  • Post belongs to Category

The category is not assigned directly to the Book, but arrives through the Post. This avoids redundancy and maintains data integrity. Furthermore, selected fields are limited within relationships so as not to fetch unnecessary columns:

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

Notice that in the Book's post() relationship, category_id is included in the select(). This is required: if Eloquent needs to resolve the nested post.category relationship, it needs that foreign key available in the result.

Quick reference for Eloquent

This short cheat sheet will be very useful to consult while writing code:

1. Prevent Lazy Loading globally

In your AppServiceProvider, add this inside the boot() method to catch N+1 problems during development:

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

2. Basic Eager Loading

// Load a relationship
$posts = Post::with('category')->get();
// Load multiple relationships
$posts = Post::with(['category', 'tags'])->get();

3. Eager Loading with conditions (nested)

When you need to filter or constrain what you fetch in the relationship:

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

Key differences: has(), with(), and whereHas()

Many people confuse these three methods. Here is the straightforward explanation:

  • with(): Loads related data. It does not filter the parent results; it only fetches the related data to prevent the N+1 problem.
  • has(): Filters. It only returns parent models that have at least one record in the relationship (e.g., User::has('posts') only retrieves users with at least one post).
  • whereHas(): Filters with additional conditions on the relationship (e.g., User::whereHas('posts', fn($q) => $q->where('status', 'published'))).

How lazy loading works

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

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

Although it seems harmless, this code executes an additional query for each iteration of the loop, triggering the classic N+1 problem: one primary query to get the posts, plus one extra query per post to retrieve its category.

The N+1 problem explained with real-world examples

In one of my projects—a post dashboard—this behavior was causing over 15 queries for a single paginated page. Performance plummeted under actual load.

To detect the source, I enabled this function in the AppServiceProvider:

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

Thanks to this, Laravel throws an exception whenever it attempts to lazy load relationships. The typical message you will see 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 the queries being executed in real time, you can use DB::listen() directly in your routes file or inside AppServiceProvider:

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

Or, if you prefer a more comfortable visual interface, install Laravel Debugbar: it is ideal for local environments and shows you in real time how many queries run per request.

That way, you will know exactly how many queries each view generates and can act before load times spike in production.

⚡ What is eager loading and when to use it

Video thumbnail

Eager loading allows you to retrieve all necessary relationships in a single additional query, rather than executing one for every record.

In this way, Laravel prepares all related data from the start, eliminating the N+1 problem at its root.

How to apply eager loading using the with() method

The most common way is passing the relationship name to the with() method:

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

With this line, Eloquent loads the posts along with their categories in two queries: one for the posts and one for all related categories. Without with(), an extra query would run for every post in the listing.

When accessing Eloquent relationships as properties (without with()), data is loaded lazily: the relationship data is not actually retrieved until the property is accessed for the first time.

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

However, Eloquent can eagerly load relationships at the moment it queries the parent model. Eager loading is precisely the solution to the N+1 query problem.

To illustrate the 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 following query with lazy loading:

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

Will execute one query to retrieve all tutorials and then one additional query per tutorial to fetch its sections. If you have 25 tutorials, the result is 26 queries (1 + 25). Here you already have the N+1 problem. But things can get even more complex if you also want to get the classes for each section:

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

The result would be catastrophic in terms of performance. Fortunately, Laravel allows us to solve this by making a single grouped database query via eager loading.

Eager Loading

In Laravel, Eager Loading is the optimization technique that reduces 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 loads related data upfront in a single operation, avoiding the need to trigger extra queries when each relationship is accessed. To do this, use the with() method, specifying the relationship name defined on the model:

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

Eager Loading with multiple relationships

If you want to load multiple relationships at the same time, including nested relationships, you can specify them in an array using dot notation for the levels:

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

Here, sections loads the first relationship and sections.classes loads the nested relationship inside each section.

Eager Loading with conditions

Often you need to apply additional constraints to what you fetch in the relationship. For that, you can pass a callback with internal constraints. In the following example, we load only classes that are published and in the correct order:

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

With this, you can build highly precise queries when loading parent and child relationships, which is especially useful when exposing data in a REST API.

Defining eager loading directly on models

If you always need a relationship to be loaded with the model, you can define it by default using the $with property:

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

With this setup, every time you query a Tutorial, its sections will load automatically without needing to append with('sections') to every query. Use it with caution: if you do not always need that relationship, it is better to load it explicitly to avoid unnecessary queries.

The with() method to load relationships with Eager Loading

When working with Eloquent relationships, we often need to load all relationships for one or several models in a single query, avoiding the use of JOINs and simplifying the code.

Imagine you have an online store with products, categories, and tags. You want to retrieve all products along with their related categories and tags.

This is where Laravel's eager loading comes into play: through the with() method, you can specify relationships right from the main query, getting fully structured data without duplicate records—something that can happen with JOINs.

Eager loading allows you to load all necessary relationships when performing the initial query. Instead of running one query for each model in the collection, Eloquent executes a single additional query to load all relationships at once. This significantly improves your application's efficiency and speed.

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

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

And now we can access all their relationships without executing 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 optimize queries by avoiding the N+1 problem in Laravel. Furthermore, having all data in a single grouped query makes it much simpler to cache if needed.

Practical example: categories and posts in Laravel

Instead of doing this (which triggers N+1 queries):

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

You can optimize it like this:

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

However, be careful: if your posts contain large fields like content, you might overload the query by bringing back data you do not need. The solution is to select only the required columns:

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

Nested eager loading with conditions

Eloquent also allows loading nested or filtered relationships very cleanly:

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

This prevents redundant queries even across multi-level structures.

Eager vs Lazy loading: comparison and performance

Video thumbnail
TechniqueGenerated QueriesPerformanceRecommended Use
Lazy Loading (Deferred loading)1 + N (one per accessed relationship)Slower if there are many relationshipsWhen only accessing one or a few specific relationships
Eager Loading (Anxious loading)1 or a few grouped queriesFaster on large collectionsWhen displaying multiple relationships simultaneously is required

The performance difference is huge when working with complex relationships or listings with many records.

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

In Laravel, the Eloquent model layer is one of the richest and most complete parts of the MVC pattern. Many methods are available, but three frequently cause confusion: has(), with(), and whereHas(). Let us review them one by one.

1. has(): Filtering models based on relationships

The has() method is used to filter selected models based on the existence of a relationship. It works similarly to a standard WHERE condition, but applied to a relationship.

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

For example, if we want to get all users who have at least one comment:

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

In short, has() acts as an existence condition: it only returns parent model records that have at least one associated record in the specified relationship.

2. with(): Loading relationships efficiently (eager loading)

The with() method is used to load relationships alongside the primary query. As seen earlier, it is the direct solution to the N+1 problem in Laravel.

Laravel will load the specified relationships in a grouped manner. This is especially useful when you have a collection of models and wish to load a relationship for all of them without multiplying queries:

$users = User::with('posts')->get();
foreach ($users as $user) {
    // Posts are already loaded, so 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 conditions on the related model. It is the way to perform a WHERE clause on an Eloquent relationship.

For example, if we want to retrieve all users who have posts created after a specific date:

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

What is whereHas()?

Video thumbnail

In short, whereHas() is the way to run queries with conditions on Eloquent relationships. Plain and simple.

Using a conventional where, you cannot directly filter across relationships. Though if you were using a join, you could seamlessly use where, which would be the traditional way.

However, when working with with(), you need whereHas() to apply conditions to the loaded relationship:

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 between the models in that example:

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: What do we use whereHas() for?

whereHas() is our mechanism for applying a conditional WHERE to an Eloquent relationship.

Keep that in mind: that is what we use whereHas() for.
For everything else, there is Mastercard… sorry, there is Eloquent.

We have this scenario in the dashboard module of this project. On one hand, the primary query in the controller:

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 lazy loading to fetch related data, so each iteration generates an extra query:

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

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

This is the N+1 problem in action: N is the page size (10 posts = 10 additional queries for categories) and +1 is the main paginated query.

Fortunately, Laravel makes it easy to detect this issue through the following setting in 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());
    }
}

Using AppServiceProvider, we can register essential configurations that are loaded when the application boots up.

If we try accessing the previous page now, we will see an error like the following:

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

Laravel's N+1 detection system is not perfect: if you had a pagination of just one record, the exception would not trigger. Even so, it is a very useful tool for spotting these issues during development.

As an extra trick, you can register a listener to observe all queries executed on every request:

routes/web.php

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

If you enable this block, you will see over 15 queries occurring: one for the authenticated user session, permissions, and roles, one for the posts, and 10 for the categories (if pagination is set to 10 records). This is great for spotting the issue, but it has the drawback that our category detail page stops working while active. To fix it, we introduce the next topic.

Let's create another example using the posts relationship defined in the category model:

app\Models\Category.php

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

If we access 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. The solution is to load the posts alongside the categories from the controller:

app\Http\Controllers\Dashboard\CategoryController.php

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

The problem with this approach is that it will fetch all posts associated with each category, and the Post model has a content column containing all the HTML of the article. Multiplied by the 10 categories in the list, the issue worsens considerably.

There are several ways to limit the columns we want to retrieve 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 approaches do not work directly when it is the category loading the posts via 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);

In this case, the solution is to properly apply eager loading to the main query, as we will see below.

Eager Loading

With eager loading we can solve the N+1 problem by performing all operations in the minimum number of queries possible. Instead of executing one query for each related record, we will only run one additional grouped query, drastically improving application performance. To do this, we specify the relationship when executing the main query:

app\Http\Controllers\Dashboard\PostController.php

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

If we go to the categories listing page now, we will see that it works correctly and without triggering extra queries.

This technique has many variations. For example, we can also define default eager loading directly in the model:

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

And the with() method can be extended to handle multi-level relationships:

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 execute queries with multiple relationships in a single call:

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

Or apply conditions to one of the relationships using a callback:

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

Preventing Lazy Loading in Laravel: 3 Ways

In this section, we will look at how to detect and prevent Lazy Loading in Laravel. We will start from the following controller code we are already familiar with:

app\Http\Controllers\Dashboard\PostController.php

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

Where a Post has a foreign relationship with categories:

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

From the view, we reference the category, and by default Laravel uses lazy loading to retrieve the related data, generating an additional query for each post in the list:

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

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

This is the N+1 problem: N is the page size (10 categories loaded from the posts) and +1 is the main paginated query.

1. Using the AppServiceProvider

Laravel allows us to easily detect this problem using 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());
    }
}

The AppServiceProvider is the ideal place for this type of global configuration that affects Eloquent behavior throughout the application.

If we now try to access the listing, we will see the following error on screen:

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

The detection system isn't perfect: if pagination was set to a single record, the exception wouldn't be thrown. Even so, it is the fastest and most direct way to detect the problem.

2. Viewing SQL Queries with DB::listen()

Another way to detect the N+1 problem is to register a listener to view in real-time the queries executed for each request:

routes/web.php

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

From the browser, you would see an SQL query printed every time the category is referenced from the post. It is fast for debugging, but not something you want to leave in production.

3. Using Laravel Debugbar

The third option is to install Laravel Debugbar, an extension displayed as a toolbar at the bottom of the browser. If you enable it with the previous listener, you will see that over 15 queries occur per request: one for the authenticated user session, permissions and roles, one for the posts, and 10 additional queries for categories (with 10-record pagination). It is ideal for detecting the issue with a clear visual interface without dirtying the HTML.

⚠️ Common Errors and Best Practices in Eloquent

Avoid Unnecessary Queries

Use with() only when you really need to load relationships in your view or logic. Do not abuse eager loading with heavy or rarely used relationships: loading more data than necessary is also a way to degrade performance.

Load Only Required Columns

You can limit the columns you fetch in each relationship using colon notation:

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

This considerably reduces the size of each query, especially when models have large fields like content or body.

Enable preventLazyLoading() Correctly

Model::preventLazyLoading() helps detect N+1 errors during development. The recommended approach is to enable it whenever you are not in production:

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

This way, it alerts you to issues locally and in staging environments, but does not interrupt application flow in production.

Conclusion

There is no single technique that is better than the other. It all depends on context and the data you need for each operation. That said, we can simplify it like this: if you are not going to use the foreign relationship, use lazy loading (or simply don't load it). If you are going to use the related data in the listing or response, use eager loading to avoid the N+1 problem.

Remember also that for each relationship specified in with(), only one additional query is added. And whenever possible, specify the columns you need instead of fetching the entire model.

There is no technique better than the other.

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

Laravel gives you the flexibility to combine both techniques and achieve optimal performance based on context.

Eloquent vs Query Builder

Laravel offers two clear pathways to optimize queries: Eloquent and Query Builder. Neither is inherently better; it all depends on context and query complexity.

  • Eloquent with with(): fetches defined relationships while avoiding the N+1 query problem. Ideal for most cases.
  • Query Builder with join or leftJoin: also allows optimization through direct joins. The syntax is different but offers more control over the generated SQL.

When to Use join or leftJoin

In some cases, especially in complex listings or APIs where I need maximum control over the data entering the query, I prefer using leftJoin directly:

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

Here I control exactly which data enters the query and avoid loading entire models that I don't need.

Select Only Required Columns

One of the most common mistakes is using SELECT * in listings. Fields such as content, body, or long texts shouldn't be loaded if they aren't used in that view.

Avoid SELECT * in Listings

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

This reduces:

  • Response size.
  • Memory usage.
  • Execution time.

Query Optimization in REST APIs

In administrative dashboards with many records, limiting columns already makes a clear difference. And in APIs, this is even more critical.

An API should:

  • Send only what the client needs.
  • Avoid unnecessarily heavy fields.
  • Reduce the number of queries.

If an endpoint is meant for listings, it makes no sense to return the entire content of each resource. The content field should only be loaded in the detail view.

Performance on Mobile Devices

On mobile devices, every byte counts:

  • Less data → faster load time.
  • Fewer queries → better battery life and user experience.
  • Faster responses → smoother apps.

Tools for Detecting Slow Queries

Before optimizing, you need to see what is actually happening. These are the tools I routinely use:

  • Laravel Telescope: allows you to view executed queries, duplicates, and execution times. It is ideal for quickly detecting N+1 issues in development.
  • Laravel Debugbar: displays queries directly in the browser view. Very convenient for day-to-day work.
  • Clockwork: works as a browser extension, similar to Debugbar but with a cleaner interface.

On more than one occasion, thanks to these tools, I uncovered unnecessary queries that were not obvious at first glance.

Final Best Practices for Optimizing Eloquent

  • Quick optimization checklist:
    • Use with() to load relationships you will use.
    • Avoid loading relationships you do not use in that view or endpoint.
    • Select only the necessary columns using select().
    • Use join or leftJoin when you need maximum control.
    • Always optimize listings and APIs.
    • Review queries using debugging tools before deploying to production.

Optimization Recommendations

1. Use with() or join

Whenever you work with relationships and listings, retrieve only the necessary data from the database. For example, by specifying columns directly within with():

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

Or by limiting fields directly inside the relationship definition in the model:

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

2. Select Only Necessary Columns

Do not retrieve large fields like content if you are not going to use them in the listing:

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

This reduces the amount of data transferred between the database and PHP, immediately improving performance.

3. Avoid Errors with select() and Relationships

These recommendations also apply to REST APIs, where it is critical to:

  • Fetch only the data the client needs.
  • Avoid unnecessarily loading heavy content.
  • Reduce the number of queries to improve speed and resource consumption.

Example using leftJoin in an 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 and Query Optimization

If you cannot limit fields directly within the query, you can do so in the relationship definition inside the model, as shown earlier. Everything mentioned regarding query optimization applies equally to a REST API, gaining special importance when handling data listings.

For example, an optimized books query for an API could look like:

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

In this case, I use leftJoin to fetch additional data from another table and specify with select() only the fields I truly need. I didn't use with() because I am handling joins directly, and the goal is to retrieve only the listing data without including heavy fields like content.

Differences and Considerations

  • When generating a list in the dashboard or for a REST API, we don't need to load full text fields (content) that would only be used in the detail view. This reduces database load and optimizes the API response.
  • If we need to display the full content, only then do we include the content field, for instance, in the book detail view.
  • On mobile devices, loading less data is crucial as it lowers page weight and improves user experience.

❓ Frequently Asked Questions

  • Which is faster: eager or lazy loading?
    • Eager loading, because it groups queries into a single SQL operation rather than executing them individually per record.
  • Can I combine both techniques?
    • Yes, Eloquent allows eager loading on certain relationships and lazy loading on others, depending on operational requirements.
  • How do I fix the "lazy loading is disabled" error?
    • The proper fix is to adjust your queries by adding with() for the relationships you use. Alternatively, you can temporarily disable preventLazyLoading() during development, though it is not a long-term solution.
  • Is it better to use Eloquent or Query Builder?
    • It depends on the case. For model relationships, Eloquent with with() is ideal. For complex queries involving multiple joins or when maximum SQL control is needed, Query Builder may be preferable.
  • Should I always use with()?
    • Only when you are actually going to use that relationship in the view or response. Loading unnecessary relationships is also a performance mistake.
  • Where is the N+1 problem most noticeable?
    • In large listings and in production, especially under high concurrent user loads or pagination with many records per page.
  • Does this apply to APIs as well?
    • Yes, even more so. APIs must be lightweight and efficient, and the N+1 problem directly impacts response times and server resource utilization.

Conclusion

Optimizing queries with Eloquent in Laravel is not complicated, but it does require discipline and attention to detail. Especially in listings and APIs, small decisions make a huge difference in production.

In my experience, thoroughly understanding relationships, avoiding redundancies, and fetching only necessary data improves performance, scalability, and project organization.

  • Always optimize your queries, especially in listings and APIs.
  • Use with() or join to minimize the N+1 issue.
  • Fetch only the columns you plan to use.
  • Avoid redundancy in your models and relationships.

Once this is clear, the next natural step is learning to debug: tools like Debugbar, Telescope, or Clockwork become indispensable for keeping your queries under control.

The next step is using unit testing in Laravel.

Is your Laravel app running slowly? Master Eager Loading and Lazy Loading in Eloquent, detect N+1 queries, and easily optimize your database performance.


Ú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:

I agree to receive announcements of interest about this Blog.