Eloquent Relationships That Quietly Load Everything

Eloquent Relationships That Quietly Load Everything

October 2, 2026
A property access that is actually a database query.

Eloquent’s best feature is that a related model looks like a property. $invoice->customer->name reads like plain object access and needs no ceremony.

That is also its worst feature, because the line gives no hint that it might issue a query, and inside a loop it issues one per iteration. The syntax that makes the easy case pleasant makes the expensive case invisible.

This is not an argument against Eloquent. It is an argument for knowing exactly which of your property accesses are queries, and for arranging the code so the question stops needing to be asked.

A property access that is actually a database query.
The syntax that makes the easy case pleasant makes the expensive case invisible

The four shapes, in order of how often they ship

Lazy loading in a loop. The classic. One query for the list, one more per row.

$invoices = Invoice::where('status', 'unpaid')->get();   // 1 query

foreach ($invoices as $invoice) {
    echo $invoice->customer->name;                        // 1 query, every iteration
}

Counting by loading. Worse than it looks, because count() on a relationship that has not been loaded fetches every row and counts them in PHP.

foreach ($projects as $project) {
    echo $project->tasks->count();     // loads every task row, to print a number
}

The nested one. Eager loading the first level and forgetting the second is an N+1 hiding behind a fix, which is why it survives review — the reviewer sees with() and stops looking.

$orders = Order::with('items')->get();       // items are loaded
foreach ($orders as $order) {
    foreach ($order->items as $item) {
        echo $item->product->name;           // product is not
    }
}

The accessor that queries. The hardest to find, because the call site looks like a string.

public function getFullAddressAttribute(): string
{
    return "{$this->line1}, {$this->city->name}, {$this->city->country->name}";
}
// Call site: {{ $user->full_address }}   -- two queries, and it reads like a field

The fixes, and the one that becomes its own problem

Each shape has a direct answer.

// Loop: load the related rows in one extra query
Invoice::with('customer')->where('status', 'unpaid')->get();

// Counting: one extra column on the original query
Project::withCount('tasks')->get();          // $project->tasks_count

// Nested: dot syntax goes all the way down
Order::with('items.product')->get();

// Several at once
Order::with(['items.product', 'customer', 'invoices'])->get();

withCount deserves particular attention because it is the one people do not know exists. It adds a subquery to the original statement and gives you tasks_count as an attribute. No rows are loaded, the count happens in the database, and it works with constraints:

Project::withCount([
    'tasks',
    'tasks as open_tasks_count' => fn ($q) => $q->where('status', 'open'),
])->get();

Then there is the fix that turns into the next problem. Putting relationships in $with on the model makes them load everywhere:

class Order extends Model
{
    protected $with = ['customer', 'items', 'invoices'];   // on EVERY query
}

Now Order::find($id) — in a queue job that only wanted the status, in an API endpoint returning three fields, in a health check — pulls three extra tables. The query count looks healthy and the payload is enormous, which is harder to notice than an N+1 and often slower.

Eager loading everything is not the opposite of lazy loading. It is the same mistake pointed the other way.

Lazy loading and eager loading everything, as two versions of the same mistake.
Eager loading everything is not the opposite of the bug — it is the same bug reversed

Make the wrong version impossible

All of the above assumes somebody spots it. Laravel has a switch that removes the need.

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

In local and staging, accessing an unloaded relationship now throws LazyLoadingViolationException with the model and the relationship named. The N+1 stops being something you find in a profiler and becomes something that fails the moment you write it.

It is off in production deliberately. A lazy load in production is a performance problem; an exception is an outage.

Turning it on for the first time on an existing codebase is uncomfortable — expect a lot of failures — so the workable route is to enable it in tests first, fix what breaks, then enable it in local. If a page genuinely needs to lazy load, the escape hatch is explicit and greppable:

$order->loadMissing('customer');    // deliberate, visible in review

Two companions are worth setting at the same time. preventSilentlyDiscardingAttributes catches a mass assignment of a field that is not fillable, which otherwise vanishes without a word. And preventAccessingMissingAttributes catches a typo in a column name that currently returns null and renders as an empty string.

Model::shouldBeStrict(! app()->isProduction());   // all three at once

Loading less, not just loading earlier

Eager loading fixes the query count and not the payload. A list page that eager loads a relationship with a large text column is now issuing two queries and moving several megabytes.

// Only the columns the page actually renders.
// The foreign key must be included or the relation cannot be matched up.
Order::with('customer:id,name,email')->get();

// Same for the parent
Order::select('id', 'customer_id', 'total', 'status')->with('customer:id,name')->get();

Forgetting the foreign key in that column list is a good bug to know about: the relationship silently comes back null for every row, because Eloquent has nothing to match on and does not complain.

For a list page that needs one field from a relation, a subquery select avoids loading the relation at all:

Order::addSelect(['customer_name' => Customer::select('name')
    ->whereColumn('customers.id', 'orders.customer_id')
    ->limit(1)
])->get();

One query, one extra column, no models hydrated. For a table of two hundred rows that is a meaningful difference — hydrating two hundred customer models to print two hundred names is work nobody asked for.

Chunking, and the loop that loads a million models

A command that processes every row is where eager loading and memory meet, and where get() stops being viable.

// Hydrates every model into memory at once
foreach (TimeEntry::with('user')->get() as $entry) { ... }

At a thousand rows that is fine. At a million it exhausts the memory limit, and the failure arrives as a fatal error in a queue worker at three in the morning.

chunkById is the version that survives. It fetches a page at a time, and unlike chunk it pages on the primary key rather than an offset, which matters if the loop modifies the rows it is reading.

TimeEntry::with('user')->chunkById(500, function ($entries) {
    foreach ($entries as $entry) { ... }
});

The offset version has a genuine bug in it that is worth understanding once. chunk uses LIMIT 500 OFFSET n. If the loop updates a column the query filters on — marking rows processed, say — then rows leave the result set as you go, everything shifts back by one page, and half the data is skipped. Nothing errors; the command reports success and did half the work.

// Skips rows: the filtered set shrinks under the offset
TimeEntry::where('processed', false)->chunk(500, fn ($rows) => $rows->each->markProcessed());

// Correct: pages on the primary key, unaffected by rows leaving the set
TimeEntry::where('processed', false)->chunkById(500, fn ($rows) => $rows->each->markProcessed());

For a read-only pass where you only need one column, cursor() is lighter still — it streams and hydrates one model at a time — and lazy() gives the same streaming with chunked queries underneath. Eager loading works with lazy() and does not with cursor(), which is the deciding factor most of the time.

Relationship counts on a page that also paginates

One combination catches people out often enough to name: withCount on a paginated query.

It works, and the subquery runs for every row on the page, which is fine. What is not fine is paginate() also running a COUNT(*) over the whole filtered set to work out the number of pages — and on a large table with a complex whereHas, that count query is frequently slower than the page of data itself.

// Two queries: the page, and a COUNT(*) over everything matching
Project::withCount('tasks')->whereHas('members', ...)->paginate(25);

// One query: no total, no page numbers, just next and previous
Project::withCount('tasks')->whereHas('members', ...)->simplePaginate(25);

simplePaginate fetches one extra row to know whether a next page exists and skips the count entirely. The cost is that the interface cannot say “page 3 of 47”, which for an infinite-scroll list or a log view is no cost at all.

Where the page numbers genuinely matter, caching the total for a few minutes is usually acceptable — the number of pages rarely needs to be correct to the second.

The morally-grey option: touching the query builder

Sometimes the Eloquent version of a query is genuinely slower than the SQL you would write, and the honest thing is to write the SQL.

$rows = DB::table('time_entries as t')
    ->join('projects as p', function ($j) {
        $j->on('p.id', '=', 't.project_id')
          ->on('p.organization_id', '=', 't.organization_id');   // scope the join
    })
    ->where('t.organization_id', $orgId)
    ->where('t.started_at', '>=', $from)
    ->groupBy('p.id', 'p.name')
    ->selectRaw('p.name, SUM(t.minutes) as total')
    ->get();

Two things to be deliberate about when you do. The query builder bypasses global scopes, so any tenant filter has to be written by hand — on every table in the join, not just the first. And the result is a collection of stdClass, not models, so accessors, casts and relationships are all gone. For a report that renders numbers, none of that is a loss.

The rule we use is that raw builder queries are allowed in a dedicated report class and nowhere else. That keeps them in one directory, where they can be reviewed as a group and where the missing scopes are obvious because there is nothing else in the file.

Filtering by a relationship without loading it

A common and expensive mistake is loading everything and filtering in PHP.

// Loads every order and every item, then throws most away
$orders = Order::with('items')->get()
    ->filter(fn ($o) => $o->items->isNotEmpty());

// Asks the database the question instead
$orders = Order::has('items')->get();
$orders = Order::whereHas('items', fn ($q) => $q->where('qty', '>', 5))->get();
$orders = Order::doesntHave('invoices')->get();

whereHas produces a correlated subquery, which is correct and can be slow on large tables. When it is, whereRelation is the flatter shorthand for the simple case, and a join is the escape hatch when the subquery plan is genuinely bad.

Order::whereRelation('items', 'qty', '>', 5)->get();

The general rule: join to filter, eager load to display. If the relationship appears in a WHERE or an ORDER BY, the database needs to see both tables. If it only appears in the output, load it separately.

When to join, when to eager load, and when to use a subquery select.
Join to filter, eager load to display — the rest follows from that

Seeing it without a profiler

Query counts are invisible unless something puts them in front of you, and that is why this class of bug persists in codebases full of careful people.

Laravel Debugbar shows the count and flags duplicates in the corner of every page. Where that is not available — an API, a queue worker, production — a middleware that appends the count to a response header takes ten minutes:

public function handle(Request $request, Closure $next): Response
{
    DB::enableQueryLog();
    $response = $next($request);
    $count = count(DB::getQueryLog());

    $response->headers->set('X-Query-Count', (string) $count);
    if ($count > 25) {
        Log::warning("{$count} queries on {$request->path()}");
    }
    return $response;
}

Keep the logging out of production or gate it behind a flag — enableQueryLog keeps every query in memory, which on a long-running worker is a leak.

Then pick a number your pages should stay under. Twenty-five is a reasonable line for a page with a list on it. The exact value matters less than having one, because a threshold turns “this feels slow” into a thing that either passed or did not.

Polymorphic relations, where the query count explodes quietly

One relationship type behaves worse than the rest under eager loading, and it is worth knowing before you add one.

A morphTo relationship points at several different tables depending on a type column. Eager loading it cannot be one query, because the related rows live in different tables — Laravel groups by type and issues one query per distinct type present in the result.

// An activity feed pointing at invoices, projects, tasks, users...
$activities = Activity::with('subject')->latest()->limit(50)->get();
// 1 query for activities, then 1 per distinct subject_type on the page

Four types on the page is five queries, which is fine. The problem arrives when the nested load differs per type, because with('subject.something') only makes sense for the types that have that relation.

// Load a different nested relation per type
Activity::with(['subject' => fn (MorphTo $m) => $m->morphWith([
    Invoice::class => ['customer'],
    Task::class    => ['project'],
    User::class    => [],
])])->get();

Without morphWith, the usual outcome is that somebody eager loads nothing and lazy loads inside the template, and an activity feed of fifty rows becomes a hundred and fifty queries.

The other trap is that whereHas does not work on a morphTo — there is no single table to subquery against. Filtering a polymorphic feed usually means filtering on the type column and the id, which is worth designing for rather than discovering.

Caching a relationship, and the stale button

Once a page is fast, the next instinct is to cache the expensive part. Relationships are an awkward thing to cache, and the awkwardness is not performance.

// Tempting, and the cache key is the problem
Cache::remember("project:{$id}:tasks", 3600, fn () => $project->tasks);

Caching a collection of models serialises them, which loses anything not in the database — computed attributes, loaded sub-relations — and brings it back as models that think they are fresh from the database. Saving one of those can write stale values over newer ones.

The safer shape is to cache the derived value rather than the models:

Cache::remember("project:{$id}:open_count", 600, fn () =>
    $project->tasks()->where('status', 'open')->count()
);

A number is unambiguous, small, and cannot be accidentally saved. And invalidation stays simple: touch the key when a task changes status, rather than reasoning about which models in a cached collection are now wrong.

What to do on Monday

Turn on Model::shouldBeStrict() in your test environment and run the suite. Whatever fails is a real N+1 or a real typo, and the list is usually shorter than expected.

Then grep for protected $with across the models. Every one of those is loading relationships on every query of that model, and at least one will be loading something that a single page needed two years ago.

Neither takes an afternoon, and between them they catch most of what this article describes.