Skip to main content
Laravel Applications

Fixing the N+1 Query Problem: How We Improved API Response Time from 3s to 200ms

How I found and fixed N+1 queries in a real Laravel course API — accessor traps, eager loading, strict mode, and query-count tests that keep it fixed.

8 min
Read time
1,498
Words
Published
Last revised
Engr Mejba Ahmed

Written by

Engr Mejba Ahmed

Share Article

Fixing the N+1 Query Problem: How We Improved API Response Time from 3s to 200ms

The N+1 query problem is usually explained as a database problem. It isn't. It's a code-shape problem: the queries that kill your API response time are triggered by property access that looks free — $course->lessons_count, $course->average_rating — inside a loop that renders twenty items. Nothing in the calling code looks like a query. That's exactly why the bug survives code review, ships to production, and only announces itself when real data arrives.

I run an e-learning platform on this site — dozens of courses with chapters, lessons, enrollments, and ratings, served both through web pages and a JSON API that my Flutter app consumes. The course listing endpoint is where I fought this exact battle: under realistic data it crawled toward the multi-second range, and after the fixes below it settled around 200ms uncached. More useful than my numbers, though, is where the queries were hiding, because the hiding places in my codebase are the same ones in yours.

Fixing the N+1 Query Problem: How We Improved API Response Time from 3s to 200ms - overview of the stealth vector: accessors that touch relationships, detection: make the invisible visible

The stealth vector: accessors that touch relationships

Everyone checks controllers for missing with(). Almost nobody checks accessors. Here are two real patterns from my Course model, simplified:

public function getLessonsCountAttribute(): int
{
    return $this->chapters->sum(
        fn (CourseChapter $chapter) => $chapter->lessons->count()
    );
}

public function getAverageRatingAttribute(): ?float
{
    $avg = $this->comments()->visible()->withRating()->avg('rating');

    return $avg !== null ? round((float) $avg, 1) : null;
}

Both look innocent. Both are grenades in a listing context.

The first one walks relationships: if chapters.lessons isn't already eager loaded, accessing lessons_count on one course lazy-loads its chapters (one query) and then each chapter's lessons (one query per chapter). On a 20-course page where each course has 5 chapters, that single "property" costs up to 120 queries.

The second one is worse in a subtler way: it calls $this->comments() — with parentheses — which always builds a fresh query, eager loading or not. Ratings on my platform live on a polymorphic Comment model, so every course card that displays a star rating fires an aggregate query per row. Twenty cards, twenty AVG() queries, every request, forever.

The arithmetic is the whole horror story. One listing page, six relationship touches per item, twenty items: you're at 120+ queries before middleware overhead. Each query might take only a few milliseconds, but they execute serially, and round-trip latency multiplies. That's how a page with no slow query in it becomes a slow page.

Detection: make the invisible visible

Two tools, both free, both in my dev setup:

  • Laravel Debugbar (barryvdh/laravel-debugbar) shows total queries and — the key metric — duplicate queries per request. A duplicate count in the hundreds is a confession.
  • Laravel Telescope's query watcher catches the same thing for API requests where there's no browser toolbar, which matters for endpoints your mobile app hits.

The diagnostic habit that finds accessor-based N+1s specifically: look at the duplicated query shape. Hundreds of select * from course_lessons where chapter_id = ? with different bindings means some property access is walking a relationship per row. Grep the model's accessors before you grep the controllers. These days the first pass of that audit in my workflow is done by an agent with database tooling — my repo wires up Laravel Boost's MCP server, so a session can inspect the schema and query log while I read the diff — but the analysis is the same one you'd do by hand.

The fix, layer by layer

Layer 1: eager load what the response actually renders

My API course listing now looks like this — the real controller, trimmed:

$query = Course::query()->published()->with('category');
// filters, search...
return CourseResource::collection(
    $query->orderByDesc('is_featured')->latest('published_at')->paginate($perPage)
);

And the detail endpoint loads the deep tree in one declaration:

$course->load(['category', 'chapters.lessons']);

Dot notation handles nesting: chapters.lessons fetches all chapters for the page's courses in one query and all their lessons in a second. If you want the fuller syntax menu — arrays, constrained closures, selective columns like 'instructor:id,name,avatar_url' — I keep a dedicated reference in eager loading nested relationships with clean array syntax.

The companion rule: the API resource should render only what was loaded. Laravel's whenLoaded() makes that contract explicit — my CourseResource emits category and chapters through whenLoaded, so a controller that forgets to eager load produces a smaller payload instead of a query storm. That's a much better failure mode.

Layer 2: move aggregates into the query

Counts and averages don't need models in memory; they need SQL:

Course::published()
    ->withCount('chapters')
    ->withAvg('comments as rating_avg', 'rating')
    ->paginate(15);

withCount() and withAvg() become subselects in the main query — one round trip, zero per-row cost, and the accessor trap disappears because the value arrives as a plain attribute (chapters_count, rating_avg). My listing controllers use withCount('chapters') for exactly this reason. Where an accessor must stay (the detail page legitimately computes lessons_count from loaded chapters), the discipline is: accessors may only walk relationships the caller is contractually required to have loaded — and the loaded-relationship check (relationLoaded()) turns that contract into code.

Layer 3: make regressions impossible, not unlikely

This is the layer that separates "we fixed it" from "it stays fixed," and it's three moves:

Strict mode in non-production. One line in a service provider:

Model::preventLazyLoading(! app()->isProduction());

Every lazy load now throws an exception in dev and CI instead of silently querying. The N+1 you would have shipped becomes a stack trace pointing at the exact access.

Query-count assertions in tests. A performance test that seeds twenty courses, enables the query log, hits the endpoint, and asserts the count stays under a threshold. It's ten lines and it converts "someone noticed the API got slow" into "CI failed on the PR that caused it."

Codify the convention where every contributor reads it. In this repo, "eager load relationships to prevent N+1 queries" is literally a numbered convention in the CLAUDE.md that both human sessions and my AI coding agents load before touching code. That's not decoration — an enforced convention plus strict mode means the pattern gets applied by default, not remembered under deadline. Guardrails beat vigilance every time.

Two force multipliers that aren't N+1 fixes

Worth naming because they compounded the win on my API:

Indexes for the actual query pattern. Eager loading collapses query count; indexes collapse query time. My listing filters on published status and sorts by publish date — a composite index on those columns turned the main query from a scan into a seek. Run EXPLAIN on your listing query; if you see a full table scan on your biggest table, no amount of eager loading will save you.

Cache what guests see, invalidate on write. This site's pattern: model observers bust the relevant cache keys on save/delete, and middleware puts public cache headers on guest pages. A course catalog changes a few times a week; there is no reason to recompute it per request. Caching is the layer you add after the queries are sane — caching an N+1 endpoint just hides the bomb until a cache miss under load. The broader server-side story is in how I optimized Laravel on shared hosting, and the Livewire-specific rendering side in my Livewire performance notes.

Quick answers

What's the fastest way to detect N+1 queries in Laravel?

Install Debugbar (or Telescope for APIs) and look at the duplicate queries count per request. Then enable Model::preventLazyLoading() in dev — from that moment, every N+1 is an exception with a stack trace instead of a silent slowdown.

What's the difference between with() and load()?

with() eager loads at query time; load() eager loads after you already have models (still one query per relationship for the whole collection). Use load() for conditional includes — like my API's detail endpoint, which loads chapters.lessons only after confirming the course is published.

Should I use withCount() even when I already eager load the relationship?

If you need only the number, yes — withCount() is a subselect and never hydrates models. Eager load the relationship and count it in PHP only when you genuinely render both the items and their count.

Why is my API still slow after eager loading?

Check three suspects in order: accessors or resource methods that call relationship methods (parentheses = new query, always), missing indexes on your filter/sort columns (EXPLAIN will tell you), and oversized payloads from loading full rows where select lists or withCount would do.

Fixing an N+1 is an afternoon. Building the layer that keeps a whole codebase free of them — strict mode, test assertions, conventions your team and your AI tools both follow — is the part most shops never get to, and it's the part that actually protects the response times. Performance work on Laravel APIs, from profiling to the guardrail layer, is one of the things I do for clients — my services page has the details if your endpoints are creeping toward the multi-second club.

Coffee cup

Enjoyed this article?

Your support helps me create more in-depth technical content, open-source tools, and free resources for the developer community.

Related Topics

Engr Mejba Ahmed

Engr Mejba Ahmed

Engr. Mejba Ahmed builds AI-powered applications and secure cloud systems for businesses worldwide. With 8+ years shipping production software in Laravel, Python, and AWS, he's helped companies automate workflows, reduce infrastructure costs, and scale without security headaches. He writes about practical AI integration, cloud architecture, and developer productivity.

Related Articles

Browse All

Comments

Leave a Comment

Comments are moderated before appearing.

Learning Resources

Expand Your Knowledge

Accelerate your growth with structured courses, verified certificates, interactive flashcards, and production-ready AI agent skills.

Sample Certificate of Completion

Sample certificate — complete any course to earn yours

Engr Mejba Ahmed

Engr Mejba Ahmed

AI assistant · trained on my work

👋

Hey there!

Quick Actions

WhatsApp Direct line to me

Chat on WhatsApp

+880 1723 741224 · Replies within the hour on working days

Popular Questions

Engr Mejba Ahmed is connected
Engr Mejba Ahmed is typing...
Engr Mejba Ahmed avatar

✉ Want me to follow up? Drop your email

Engr Mejba Ahmed avatar

📞 Connect Directly

Choose how you'd like to reach me

WhatsApp

+880 1723 741224

Email

mejba.13@gmail.com

✓ Details sent! I'll get back to you shortly.

Powered by OpenAI

335+

Blog Posts

25

AI Courses

63

Projects

Services & Expertise

Pricing & Process

Learning & Resources

Connect & Support