Overusing `with()` and Missing the Real N+1 in Laravel

2026-08-297 min readTwixr Solutions

Cover image for Overusing `with()` and Missing the Real N+1 in Laravel

Overusing with() and Missing the Real N+1 in Laravel

You added with() everywhere. Telescope still shows 200 queries on a page that should have 4.

That is the trap. with() solves one class of N+1. Developers treat it like a blanket fix, stop looking, and ship the problem anyway - just a slightly different version of it.

This post is about the N+1 patterns that with() does not touch, and how to actually find and close them.


Why with() Earns False Confidence

Eager loading via with() prevents the classic loop problem:

// Bad: fires one query per post
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; // SELECT * FROM users WHERE id = ?
}

// Better: two queries total
$posts = Post::with('author')->get();

That fix is real. Two queries instead of N+1. Good.

But with() only works when you know the relationship ahead of time, at the point you build the initial query. The moment the relationship is accessed somewhere else in the request lifecycle, you are back to firing individual queries.

Before and after code card showing N+1 with Eloquent with() fix and its blind spots


The Four Places with() Cannot Help You

1. Lazy loading triggered inside a service or helper

You eager-load on the controller. Then you pass the collection into a service class that calls a different relationship you forgot to include.

// Controller
$orders = Order::with('customer')->get();
OrderExportService::handle($orders);

// Inside OrderExportService
foreach ($orders as $order) {
    $order->items->each(...); // 'items' was never eager-loaded
}

Each $order->items fires a query. You have N+1. Telescope will show it. Most developers do not look.

The fix: audit every relationship touched downstream from your initial query. Add them all to with(), or use loadMissing() before the service runs.

2. Accessors and computed attributes that hit the database

A custom accessor looks like a property access. It reads like safe cached data. It is not.

// App\Models\Product
public function getIsPopularAttribute(): bool
{
    return $this->orders()->count() > 100; // COUNT query per product
}

Now iterate over 50 products and call $product->is_popular in a Blade loop. That is 50 extra queries. with() does not know this accessor exists. There is no relationship name to eager-load.

The fix: push this logic to a query scope with a sub-select, or store it as a computed column updated via an observer or scheduled job.

3. Polymorphic relationships loaded without morphMap

Polymorphic eager loading works, but without a morphMap, Laravel resolves the morph type using the full class name stored in the database. If your morph types are inconsistent across environments, resolution silently falls back to individual queries.

More commonly, developers eager-load one side of the polymorphic relation and forget the other:

// Only loads commentable for Comment, not the nested author on each commentable
$comments = Comment::with('commentable')->get();

foreach ($comments as $comment) {
    echo $comment->commentable->author->name; // fresh query per commentable
}

The fix: chain nested eager loading - with('commentable.author').

4. Conditional relationships inside loops

This one is subtle. You check a condition, then load a relationship based on it.

foreach ($users as $user) {
    if ($user->role === 'admin') {
        $user->load('permissions'); // individual query per admin user
    }
}

with() on the parent query cannot conditionally eager-load per row. You end up with one query per matching row.

The fix: use with() with a constrained sub-query, or partition the collection before the loop and loadMissing() on the subset.

$users->filter(fn($u) => $u->role === 'admin')->loadMissing('permissions');

How to Actually Find These

Running the app and hoping is not a strategy. Use these tools in combination:

  • Laravel Telescope - query panel. Sort by count. Anything above 10 queries for a single request needs explaining.
  • Laravel Debugbar - shows query count and caller stack in local dev. The caller column tells you which file and line triggered the query.
  • DB::listen() - add it to a service provider in local env. Log every query with its caller. Useful for finding queries buried inside services or jobs.
  • preventLazyLoading() - the single most useful method for catching this in CI:
// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

This throws a LazyLoadingViolationException any time a relationship is accessed without being eager-loaded. Run your test suite with this enabled. Every violation surfaces immediately, before it reaches production.

Diagram showing Laravel Debugbar and Telescope side by side, highlighting a query count spike and its stack trace caller


A Pattern Worth Building Into Every Project

Before shipping any list endpoint, do a quick query audit:

  1. Open Telescope or Debugbar on the route.
  2. Count queries. Write the number down.
  3. For any count above (relationships + 1), trace each extra query to its caller.
  4. Fix at the source - eager load, sub-select, or cache.
  5. Recount. Target is relationships + 1, ideally.

For a typical paginated list with two relationships, you should see 3 queries: one for the list, one per relationship. If you see 30, you have at least one unaddressed N+1.

This takes 10 minutes per endpoint. It saves hours of production debugging later.


The Broader Point

with() is a tool for a specific problem. Treating it as a general-purpose performance fix leads to over-eager loading (fetching columns and relationships you never use) alongside under-eager loading (missing the ones that actually matter).

The real discipline is: instrument every list query, read the output, and fix what the output tells you - not what you assume you already fixed.

I have seen production Laravel apps with with() on every query and still 300+ queries per page load because nobody checked what the accessors, services, and polymorphic chains were doing underneath.

preventLazyLoading() in your test suite is the cheapest guard you have. Turn it on today.

Before/after comparison: a Laravel controller query with missing eager loads vs the corrected version using nested with() and loadMissing(), annotated with query counts


Quick Reference: N+1 Fix Cheatsheet

ScenarioRoot CauseFix
Loop over collection, access relationMissing with()Add relation to with()
Accessor fires a query per rowDB call inside attribute methodSub-select or computed column
Polymorphic chain, nested relationIncomplete eager loadwith('morphable.nestedRelation')
Conditional relation load inside loopPer-row load()Partition + loadMissing() on subset
Service/helper accesses unexpected relationEager load scope too narrowAudit downstream callers, extend with()

Spent enough time chasing phantom N+1s in codebases that looked "fixed" on the surface. preventLazyLoading() caught more real issues in the first test run than a week of manual Telescope review.

What is the most unexpected place you found an N+1 hiding in a Laravel app?

Frequently asked questions

No. with() prevents N+1 only for relationships you explicitly name at the point of the initial query. Relationships accessed later inside services, accessors, or conditional loops still fire individual queries. You need to audit the full call chain, not just the controller.

Enjoyed this article?

Get notified when I publish new posts on SaaS, Laravel, and remote engineering.

Keep reading

More posts

August 26, 20267 min read

Queued Jobs vs Scheduled Commands in Laravel - When Each One Actually Fits

Picking the wrong tool slows your app and burns debugging hours. Here's a practical breakdown of when to reach for a queued job versus a scheduled command in La

Read

2026-09-187 min read

Partial and Composite Indexes: The Ordering Rule That Kills Slow Queries

Get composite and partial index ordering wrong and your query planner ignores the index entirely. Here is the rule, with concrete examples.

Read

2026-08-277 min read

Model Observers vs Events vs Listeners in Laravel: Pick the Right Tool

Model observers, events, and listeners all hook into your app's lifecycle - but using the wrong one creates tangled, untestable code. Here's how to choose.

Read