Skip to main content
David Dew Mallick

David Dew Mallick

ProjectsExperienceSkillsBlog
Back to blog
eloquent n+1laravel best practiceslaravel cachinglaravel queue

Eloquent Relationships: Eager Loading Deep Dive

How Laravel eager loading actually works, when with() stops helping, and how to detect and fix relationship queries that quietly multiply per row.

Sep 26, 2026 · 6 min read

Eloquent relationships are the part of Laravel that looks simplest and costs the most. A single missing with() on a list endpoint can turn one query into 500, and the symptom rarely shows up in development because your local table has 40 rows. This guide covers how eager loading actually executes, where it silently stops working, and how to verify the fix with real query counts.

The core rule

Lazy loading is fine for a single model. For any query that returns a collection, every relationship you touch in the view or resource layer must be eager loaded, or you pay one round trip per row.

What Laravel actually does with with()

Eager loading is not a join. When you write Post::with('author')->get(), Laravel runs two queries: one for the posts, then one where id in (...) for every distinct author ID it collected. That second query is why eager loading is fast even when the parent set is large, and also why it behaves differently from a join when you add constraints.

// Two queries total, regardless of post count
$posts = Post::with('author')->limit(100)->get();

// One query, but rows are duplicated per relation and
// limit() now applies to the joined row set, not the posts
$posts = Post::join('users', 'users.id', '=', 'posts.user_id')
    ->select('posts.*')
    ->limit(100)
    ->get();

That limit interaction is the most common source of confusion. A join with limit(100) can return fewer than 100 posts if authors have multiple posts matching, because the limit counts joined rows. Eager loading keeps the limit on the parent query and is almost always what you want.

Nested and constrained eager loads

Nested relations use dot notation and execute one query per level, not one per parent. Constrained eager loads attach a where to the child query, which is where people accidentally reintroduce the N+1 pattern.

// 3 queries: posts, authors, comments
$posts = Post::with(['author', 'comments.replies'])->get();

// Constraint applies to the child query, still 2 queries
$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)->latest()->limit(5);
}])->get();

// WRONG: this runs one query per post
$posts = Post::all();
foreach ($posts as $post) {
    $top = $post->comments()->where('approved', true)->limit(5)->get();
}

The constrained version inside with() has a known limitation: limit() on a has-many eager load applies globally in some database drivers rather than per parent. If you need exactly N children per parent, use HasMany::latestOfMany() for a single child or a window-function query for N. Verify by inspecting the generated SQL with DB::listen() rather than assuming.

Where eager loading silently stops working

Three situations account for most production N+1 regressions I have debugged.

1. Accessors that query

An accessor that calls $this->relation()->count() bypasses eager loading entirely because it builds a new query builder each time. The fix is to eager load with a count or use withCount().

// Adds a comments_count attribute, one extra query total
$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count; // no query
}

2. Serialization through API resources

If a resource class references $this->author->name and the controller forgot with('author'), the query fires during serialization, after your controller has returned. That makes it invisible in controller-level logging. Enable strict mode in development so it fails loudly instead.

// AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

This throws a LazyLoadingViolationException the moment an unloaded relationship is touched. It is the single highest-value line you can add to a Laravel codebase, and it costs nothing in production because it is disabled there.

3. Relations loaded inside loops over chunks

Chunking a large export and calling with() per chunk is correct, but calling $model->load() per model inside the chunk is not. Move the load() call above the loop or use with() on the chunk query.

Detecting the problem with real numbers

Do not guess at query counts. Log them. This listener prints every query with its bindings and timing, which is enough to spot a loop pattern immediately.

use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    logger()->debug('sql', [
        'sql' => $query->sql,
        'bindings' => $query->bindings,
        'ms' => $query->time,
    ]);
});

A healthy list endpoint shows a flat query count as row count grows. An N+1 shows a linear increase. Run the same request against 10 rows and 100 rows and compare the log line count. If it grew by roughly 90, you have found it.

ApproachQueries for 100 postsTrade-off
Lazy loading in a loop101Simple to write, scales linearly with rows
with('author')2Requires knowing relations upfront
withCount('comments')2Adds a subquery, cheap on indexed FK
Join with select1Row duplication, limit semantics change

Eager loading and caching interact badly

If you cache an Eloquent collection that was eager loaded, you cache the models and their loaded relations together. That is usually what you want, but the cache key must include everything that affects the relation set. Caching Post::with('comments')->get() under a key that does not change when a new comment is added serves stale relation data even though the post itself did not change.

The practical fix is to cache the query result under a tag or key that your comment-creation path also invalidates, or to cache only scalar aggregates like comments_count. Full collections with loaded relations are rarely worth caching unless the underlying tables are read-mostly. See Laravel caching strategies for invalidation patterns that hold up under writes.

When you cache a serialized collection, cache the array form (->toArray()) rather than the Eloquent objects. Hydrating hundreds of models from cache on every request costs more than the query you saved.

Relations inside queued jobs

Eloquent models are serialized into queue payloads by identifier, then re-fetched when the job runs. Any relations you loaded before dispatch are gone. If a job needs $order->customer->email, load it inside the job with $order->load('customer') or query fresh, and expect the customer to have changed since dispatch.

class SendReceipt implements ShouldQueue
{
    public function __construct(public Order $order) {}

    public function handle(): void
    {
        // Relations are not restored from the payload
        $this->order->loadMissing('customer');

        Mail::to($this->order->customer->email)
            ->send(new ReceiptMail($this->order));
    }
}

loadMissing() is the right call here: it skips the query if the relation is already loaded, which matters when the job is dispatched synchronously in tests. For retry and failure handling around these jobs, see Laravel queue best practices.

FAQ

Does with() work with pagination?

Yes. Eager loads run after the paginated parent query, so the query count stays constant per page. The relation queries do include all IDs on the current page, so a page size of 500 means the child in (...) clause has up to 500 values.

Should I always eager load?

No. Loading a relation you never read wastes a query and memory. Load what the response actually touches, and let preventLazyLoading catch the cases where you guessed wrong.

Is withCount() cheaper than with()?

Usually. It returns an integer instead of hydrating child models, so memory stays flat. Use it whenever you only need the number, and reserve with() for when you render the children.


The verification loop is short: enable preventLazyLoading outside production, log query counts at two data sizes, and treat any linear growth as a bug. That catches the relationship mistakes that survive code review, because they are invisible until the table grows.

DD

David Dew Mallick

Software Engineer

I build AI-driven SaaS infrastructure and backend systems with Laravel, AWS, and SQL, and write about the engineering decisions behind them.

GitHubLinkedInEmail

More posts

  • Sep 10, 2026 · 6 min read

    Fix Eloquent N+1 in Laravel: Query Problems at Scale

    Eloquent N+1 problems quietly destroy Laravel performance. Learn to detect, fix, and prevent them with eager loading, chunking, and query auditing in production.

  • Sep 22, 2026 · 8 min read

    Laravel Middleware: A Practical Guide to Request Flow

    Laravel middleware controls every request your app handles. Learn to write, register, and order middleware for auth, throttling, and request mutation.

End of record

Back to top↑

David Dew Mallick

Dhaka, Bangladesh

Contact

  • david.dew.mallick@g.bracu.ac.bd
  • GitHub
  • LinkedIn

2026 David Dew Mallick