Laravel listing page making 300+ queries: fixing Eloquent N+1 with eager loading
Our job board listing took 1.8 seconds and ran 312 queries per page. Blade was lazy loading company, tags and application counts for every row. We fixed it with with(), withCount() and made lazy loading throw in development.
The public listing page of our job board, /jobs, slowly got worse over three months until the
p95 time to first byte reached 1.8 seconds. Nobody had changed the controller in that time. What
had changed was the Blade view: designers added the company logo, a row of skill tags, the
recruiter's name and an "N applicants" badge to each card.
Laravel Debugbar on a local copy of production data told the story in one line of its Queries tab:
312 statements were executed, 290 of which were duplicates, 22 unique 1.12s
The query list was the same four statements repeated for every job card:
select * from `companies` where `companies`.`id` = 1841 limit 1 0.9ms
select `tags`.*, `job_post_tag`.`job_post_id` as `pivot_job_post_id`, `job_post_tag`.`tag_id` as `pivot_tag_id`
from `tags` inner join `job_post_tag` on `tags`.`id` = `job_post_tag`.`tag_id`
where `job_post_tag`.`job_post_id` = 99213 1.4ms
select * from `users` where `users`.`id` = 5520 limit 1 0.7ms
select count(*) as aggregate from `applications` where `applications`.`job_post_id` = 99213 3.8ms
Seventy-five jobs per page, four queries each, plus pagination, auth and layout queries.
Reproducing it
php artisan tinker
DB::enableQueryLog();
$jobs = App\Models\JobPost::latest()->paginate(75);
foreach ($jobs as $job) {
$job->company->name;
$job->tags->pluck('name');
$job->recruiter->name;
$job->applications()->count();
}
count(DB::getQueryLog()); // 302
What we ruled out
- Slow individual queries. The slowest was the count on
applicationsat under 4 ms, and it had an index onjob_post_id. The total was slow because of volume and round trips, not any one statement. - Database load. RDS CPU sat at 12 percent. Each request simply waited for 300 sequential network round trips of roughly 1 to 4 ms.
- The view cache.
php artisan view:cachewas already part of the deploy; compiled Blade is not the bottleneck when the template triggers database calls.
Root cause
The controller fetched jobs without any relationships:
public function index(Request $request): View
{
$jobs = JobPost::query()
->where('status', JobStatus::Published)
->latest('published_at')
->paginate(75);
return view('jobs.index', compact('jobs'));
}
The Blade partial then accessed $job->company, $job->tags, $job->recruiter and called
$job->applications()->count(). Each property access on an unloaded relationship makes Eloquent
lazy load it with its own query. That is the N+1 pattern: one query for the list, then N more for
each relation, multiplied by the number of relations the view touches.
What is happening under the hood
An Eloquent relationship property such as $job->company is resolved by a magic __get. If the
relation has not been loaded, Eloquent runs the relationship query right there and caches the result
on that one model instance. Nothing is shared between instances, so 75 jobs mean 75 identical-shaped
queries.
Eager loading reverses this: JobPost::with('company') runs the list query, collects all
company_id values, and runs a single where id in (...) query, then hydrates the relation on every
model. For belongsToMany it does the same through the pivot table.
Counts are a separate trap. $job->applications()->count() calls the relationship method, which
always builds a fresh query; eager loading applications would not help and would load thousands
of rows just to count them. withCount('applications') adds a correlated subquery to the list
query and exposes applications_count on each model.
Environment
| Component | Version / value |
|---|---|
| Framework | Laravel 11.41 |
| PHP | 8.3.16, PHP-FPM, OPcache on |
| Database | MySQL 8.0.40 on RDS, same region, about 1 ms round trip |
| Debug tooling | barryvdh/laravel-debugbar 3.14 (local only) |
| Page size | 75 jobs per page |
Minimal reproduction
// resources/views/jobs/_card.blade.php (before)
<article class="job-card">
<img src="{{ $job->company->logo_url }}" alt="{{ $job->company->name }}">
<h3>{{ $job->title }}</h3>
<p>{{ __('Posted by :name', ['name' => $job->recruiter->name]) }}</p>
<ul>
@foreach ($job->tags as $tag)
<li>{{ $tag->name }}</li>
@endforeach
</ul>
<span>{{ trans_choice(':count applicant|:count applicants', $job->applications()->count()) }}</span>
</article>
Who is affected
Any Laravel version, and every ORM with lazy loading (Rails Active Record, Django ORM, Hibernate,
Doctrine). It hides well in development with ten seeded rows and appears as data and page sizes
grow. API resources (JsonResource) that read relations are a common hiding place too.
The fix
We eager loaded everything the view needs and replaced the count call with withCount. We also
selected only the columns the card uses, because companies has a large description column:
public function index(Request $request): View
{
$jobs = JobPost::query()
->where('status', JobStatus::Published)
+ ->with([
+ 'company:id,name,logo_path',
+ 'recruiter:id,name',
+ 'tags:id,name,slug',
+ ])
+ ->withCount('applications')
->latest('published_at')
->paginate(75);
return view('jobs.index', compact('jobs'));
}
When you constrain columns with the relation:col1,col2 syntax, include the key Eloquent matches on
(id here), otherwise relations come back empty.
-<span>{{ trans_choice(':count applicant|:count applicants', $job->applications()->count()) }}</span>
+<span>{{ trans_choice(':count applicant|:count applicants', $job->applications_count) }}</span>
Then we made lazy loading an error outside production, in AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
With that in place, any new lazy load throws locally and in CI:
Illuminate\Database\LazyLoadingViolationException
Attempted to lazy load [company] on model [App\Models\JobPost] but lazy loading is disabled.
In production we did not want a forgotten relation to become a 500 error, so we log instead:
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
if (app()->isProduction()) {
logger()->warning('Lazy loading violation', [
'model' => $model::class,
'relation' => $relation,
'url' => request()?->fullUrl(),
]);
return;
}
throw new \Illuminate\Database\LazyLoadingViolationException($model, $relation);
});
Alternatives and trade-offs
$model->loadMissing()in the view composer. Fine when one partial is reused by many controllers, but it spreads query logic into the view layer.Model::shouldBeStrict(). Turns on lazy-loading prevention plus errors for silently discarded attributes and missing attributes. Great for new projects; on an old codebase it surfaced dozens of unrelated issues at once, so we enabled the three options one at a time.- A
$withproperty on the model. Always eager loads a relation. Convenient, but every query pays for it, including ones that never use it. We avoided it. - Caching the rendered card. Helps repeat views but hides the problem and complicates invalidation when a job gets a new applicant.
How to test the fix
A feature test that asserts an upper bound on queries keeps the page honest as the view grows:
public function test_jobs_index_runs_a_constant_number_of_queries(): void
{
JobPost::factory()->count(75)->hasTags(3)->hasApplications(5)->create();
DB::enableQueryLog();
$this->get('/jobs')->assertOk();
$this->assertLessThanOrEqual(12, count(DB::getQueryLog()));
}
php artisan test --filter=test_jobs_index_runs_a_constant_number_of_queries
Because preventLazyLoading is on in the testing environment, the test also fails loudly if a new
relation is accessed without eager loading.
Confirming in production
| Metric | Before | After |
|---|---|---|
Queries per /jobs request |
312 | 9 |
| Time in database per request | 1,120 ms | 38 ms |
p95 TTFB for /jobs |
1.8 s | 210 ms |
| RDS read IOPS during peak | about 2,400 | about 900 |
We read these from our APM's per-route breakdown for the week before and after. The lazy-loading
warning log stayed empty for /jobs, and caught two other pages (/companies/{slug} and the admin
applicant export) on day one, which we fixed the same way.
Regression guard
preventLazyLoadingthrows in local, testing and CI.- The query-count test exists for the five highest-traffic pages.
- Production lazy-loading warnings are counted on a dashboard; a non-zero daily count opens a ticket.
Security implications
None for the fix itself. One thing to check when you add column constraints to eager loads: do not
select sensitive columns you would otherwise have avoided, and remember that toArray() on a model
serialises loaded relations. If you return models directly as JSON, use $hidden or API Resources
so an eager-loaded recruiter does not leak an email address into a public endpoint.
Prevention checklist
- Enable
Model::preventLazyLoading()outside production on every project. - Use
withCount,withSumorwithExistsinstead of calling relation methods in loops. - Assert a maximum query count in feature tests for list pages and API collections.
- Review Blade and API Resource diffs for new relation access.
- Keep Debugbar or Telescope available locally with production-sized data.
References
- Laravel 11.x documentation: Eloquent Relationships, Eager Loading — https://laravel.com/docs/11.x/eloquent-relationships#eager-loading
- Laravel 11.x documentation: Preventing Lazy Loading — https://laravel.com/docs/11.x/eloquent-relationships#preventing-lazy-loading
- Laravel 11.x documentation: Counting Related Models — https://laravel.com/docs/11.x/eloquent-relationships#counting-related-models
- Laravel 11.x documentation: Configuring Eloquent Strictness — https://laravel.com/docs/11.x/eloquent#configuring-eloquent-strictness
- Laravel Debugbar — https://github.com/barryvdh/laravel-debugbar
Add a label
Embed this post
<iframe src="https://hibug.me/laravel-eloquent-n-plus-one-eager-loading" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.