Priya Raman
Nine out of ten performance regressions I have diagnosed in production were the same bug wearing different clothes: an unloaded relation inside a loop that nobody noticed because the staging database has 40 rows.
Link copied to clipboard
The bug is almost never a slow query. It is a fast query executed four hundred times.
The shape of the problem
A blog index renders nine posts, each with a category and up to four tags. The naive implementation looks harmless:
$posts = Post::query()->published()->recent()->paginate(9);foreach ($posts as $post) {
echo $post->category->name; // one query per post
foreach ($post->tags as $tag) { // one query per post
echo $tag->name;
}
}
```
That is one query to fetch the page, then one per post for the category, then one per post for the tags. Twenty-eight queries for a nine-row page. On a staging database with forty posts it completes in 12ms and nobody looks twice.
Why it survives code review
Lazy loading is not a mistake anyone makes deliberately. It is a mistake the ORM actively encourages, because the syntax for the loaded and unloaded cases is identical. $post->category reads the same whether it costs zero queries or one, and review focuses on the algorithm rather than the access pattern.
This is a property of the tool, not a failure of discipline. Design the system as if the developer will not notice — because they will not.
Three defences, in order of reliability
1. Make the query count assertable in tests
The most reliable defence is a test that fails when the query count changes:
test('the blog index does not issue one query per post', function () {
Post::factory()->count(9)->published()->create();DB::enableQueryLog();
$this->get(route('blog.index'))->assertOk();
expect(DB::getQueryLog())->toHaveCount(3);
});
```
The exact number matters less than the fact that it is pinned. A change from three to twenty-eight fails the suite, and the failure message names the page.
2. Disable lazy loading outside production
Laravel can be told to throw instead of silently querying:
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}This turns a silent performance defect into an exception with a stack trace pointing at the exact property access. The cost is that some legitimate lazy loads now throw and must be wrapped in ->loadMissing().
That friction is the point. It forces an explicit decision about every relation access in a loop.
3. Push the shape into a query-budget layer
For the highest-traffic endpoints, make the budget part of the contract:
$posts = Post::query()
->published()
->with(['category:id,name,slug', 'tags:id,name,slug'])
->recent()
->paginate(9);Selecting only the columns you render keeps the payload small, and the with() makes the eager load unconditional — it cannot be skipped by a later refactor that removes the loop.
What I do differently now
I stopped treating N+1 as a performance issue and started treating it as a correctness issue. A page that issues twenty-eight queries will behave differently on a database a hundred times larger, which means the staging environment is not testing the production behaviour. That is a correctness gap, not a tuning opportunity.
The concrete change: every listing endpoint gets a query-count assertion on the day it is written, not on the day it becomes slow.
Keep reading
Discussion
-
PThis is the post I keep sending to new hires. The `with()` example finally made eager loading click for one of my teammates.
-
M
Marcus Webb
replyGlad it landed. The `with()` vs `load()` distinction is the one I see trip people up most often in review.
-
-
C
Clara Nowak
The index write-path trade-off is under-discussed. Everyone wants to add indexes; nobody wants to measure the insert cost.