Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · February 04, 2025 · 8 min read · 7 Views
Banner: a Laravel listing page firing hundreds of small database queries collapsed into a few eager-loaded ones

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 applications at under 4 ms, and it had an index on job_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:cache was 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 $with property 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

  • preventLazyLoading throws 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, withSum or withExists instead 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

Add a label

#laravel #php #mysql #eloquent #n-plus-one
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.