artisanplaybook .dev
ARTICLES / PERFORMANCE

The eager load that made your page slower

Fixing N+1 is the easy half. The eager load you reach for next pulls columns you never render, and one of its shorthands fails silently.

ON THIS PAGE

Three queries, and slower than two hundred

You found the N+1 on the orders index, added with('customer'), and watched the query count drop from 203 to 3. The page got slower.

Nothing is wrong with the fix. The problem is what with() actually does: it runs select * from customers where id in (...). Every column. Your customers table has a notes TEXT column averaging 4KB, and the index renders a name. You are now pulling 800KB over the wire and hydrating 200 full models to print 200 strings — in three queries instead of 203, which is why the counter said you had won.

Query count is a proxy. It is a good proxy for N+1 specifically, and a bad one for everything after, because the work moves into rows, bytes and hydration where the counter cannot see it. The N+1 article on this site covers finding and fixing the missing eager load; this is about the four ways the eager load you added is itself the problem.

$with on the model charges every query in the application

The most expensive version of this is the one that looks tidiest:

class Order extends Model
{
    protected $with = ['customer', 'lineItems'];
}

Now every query that touches Order loads both relations. The index that renders them, yes — but also the webhook that reads $order->status, the job that updates a timestamp, the count on the dashboard, the findOrFail() in the middleware. Each one silently pays for two extra queries and a tree of hydrated models it never looks at.

You can opt out per query:

$orders = Order::without('lineItems')->get();

$orders = Order::withOnly('customer')->get();

But if you find yourself writing without() in more than one or two places, the default is wrong. Load relations at the call site that needs them. $with is for relations an instance is genuinely meaningless without — a polymorphic owner some accessor always dereferences — not for the ones a particular screen happens to render.

Ask for columns, and remember the key

The fix for the 4KB notes column is to name what you need:

$orders = Order::with('customer:id,name')->get();

This is the shorthand with a trap in it. The column list goes into the relationship's own select, so if the key that stitches the relation back together is missing, Eloquent has nothing to match rows against — and it does not warn you. It hands back null.

Tested against this application's Article model, which belongs to Media:

$good = Article::with('heroMedia:id,original_name')->first();
$bad  = Article::with('heroMedia:original_name')->first();

// with id in select    -> loaded: x.png
// without id in select -> NULL

No exception, no log line. A template doing {{ $article->heroMedia?->original_name }} renders an empty string, and you conclude the record has no hero image. Always include the related model's key, plus any foreign key the relation depends on — for a hasMany, that is the column pointing back at the parent.

This is worth keeping out of your models' casual reach. A column-limited eager load makes the returned model a partial: saving it, or passing it to code that expects the whole row, gets you behaviour that depends on which columns you happened to list.

Do not load rows in order to count or total them

If a screen shows "14 line items" and never lists them, loading 14 models to call count() on the collection is work you can delete outright. The withCount family pushes it into the database as a subquery and returns a scalar:

$orders = Order::withCount('lineItems')
    ->withSum('lineItems', 'amount')
    ->withExists('refunds')
    ->get();

// $order->line_items_count, $order->line_items_sum_amount, $order->refunds_exists

withExists is the one people reach for least and want most: for "does this order have any refunds", counting all of them to compare against zero is strictly more work than asking whether one exists.

Two rules stop these going wrong. Alias when you need the same relation twice — withCount(['lineItems', 'lineItems as damaged_count' => fn ($q) => $q->where('damaged', true)]) — and if you are also narrowing the parent's columns, call the aggregate after select(). Reverse that order and the aggregate is dropped from the query, and you get an undefined attribute at render time rather than an error at query time.

One value from a relation is a subquery, not a relation

"The date of the most recent payment" is the case where eager loading fits worst. with('payments') to read one field from the newest row loads every payment for every order so you can throw nearly all of them away.

Select the single value instead:

$orders = Order::addSelect(['last_paid_at' => Payment::select('created_at')
    ->whereColumn('order_id', 'orders.id')
    ->orderByDesc('created_at')
    ->limit(1)
])->get();

One query, one extra column, no hydration. orderByDesc() accepts the same subquery, so you can sort the index by last payment without loading a single Payment model.

Laravel 13 also ships Model::automaticallyEagerLoadRelationships(), which lazy eager loads relations across a whole collection the first time you touch one. It is a good safety net against N+1 reappearing. It is documented as beta and may change on a patch release, and it fixes query count rather than any of the costs above — the automatically loaded relation still pulls every column.

What to change

Open the slowest index page you have and look at the actual SQL, not the query count. For each eager load, answer one question: which columns of this relation does the response render?

If the answer is "one or two", narrow it and include the key. If the answer is "none, I just need a number", move it to withCount, withSum or withExists. If the answer is "one field from the newest related row", make it a subquery select. If the answer is "all of them", the eager load is right and you can leave it alone.

Then grep for protected $with across your models. Every one of those is a cost charged to every query that touches the model, including all the ones that never render a relation.

Related reading

ARCHIVE →