LaravelEloquentN+1performancePHP

Laravel N+1 Query Problem: How to Detect and Fix It

The Laravel N+1 query problem is when one listing query is followed by one extra query per row. You spot it in Telescope or the query log, then fix it with with(), withCount(), and nested eager loads.

Laravel N+1 Query Problem: How to Detect and Fix It

Quick Answer

N+1 is Eloquent being lazy in a loop. You load posts with one query, then $post->author or $post->comments fires a new SELECT for every row — 1+N, not a slow join. I look for it on index endpoints and Blade listings. Telescope (or a query log) shows the same SQL with a changing post_id. Put with() or withCount() on that listing query. Do not “fix” it inside the foreach.

Quick Facts

ItemDetails
TopicLaravel N+1 Query Problem
CategoryLaravel / Eloquent / Performance

What Is Laravel N+1 Query Problem?

Laravel did not invent this. Any ORM will do it if you walk a collection and poke relations that were never loaded.

Eloquent just makes it easy to miss. Post::latest()->get() looks finished. Then the resource or the Blade file reads $post->author->name and you get another round trip. Eight posts on your laptop? Fine. Two hundred on production and the same code sits in Telescope like a drumbeat: where post_id = 1, = 2, = 3

A slow join is one fat query. N+1 is a pile of skinny ones. If you already wired Eloquent relationships and never called with(), you basically opted into this.

Why It Matters

  • Page size 20 can already be ~60 queries if you touch author, tags, and comments. Bump to 100 and you are in the hundreds. The app does not “feel a bit slow.” Connections stack up.
  • $post->author->name is the kind of line nobody flags in review. It only blows up in a loop.
  • JSON APIs fail first. The SPA still paints a skeleton; the DB is already busy. I have watched Nuxt wait on /api/posts while Laravel burned a query per field per row.

How It Works

Parents load. Relations stay empty until you read them as a property. Property access in a loop = one query per parent. That is the whole trick.

flowchart TB
  Q0["Query 0: SELECT * FROM posts"]
  Q0 --> P1[Post 1]
  Q0 --> P2[Post 2]
  Q0 --> PN[Post N]
  P1 --> Q1["Query 1: comments WHERE post_id = 1"]
  P2 --> Q2["Query 2: comments WHERE post_id = 2"]
  PN --> QN["Query N: comments WHERE post_id = N"]
This is what Telescope looks like: one posts query, then comments over and over.

with('comments') changes the second step. Eloquent grabs the post ids and runs one WHERE post_id IN (…). Same comments. Query count stops tracking how many cards you rendered.

flowchart LR
  A["Query 1: SELECT * FROM posts"] --> B["Collect post ids: 1,2,…N"]
  B --> C["Query 2: SELECT * FROM comments WHERE post_id IN (…)"]
  C --> D["Attach comments onto each Post in memory"]
Two trips for one relation. Add more posts to the page — still two trips.

I keep three cases in my head:

  1. You actually need the related models → with('comments').
  2. The UI only shows “12 comments” → withCount('comments') and read comments_count. Hydrating twelve Comment models to call ->count() is how memory dies on a popular post.
  3. You already have the collection in hand → $posts->load('tags') once. Not $post->load() inside foreach. That last one is still N+1 with extra steps.
flowchart TB
  subgraph bad [Need only a number]
    B1[Load all Comment models] --> B2[Call count in PHP]
  end
  subgraph good [Need only a number]
    G1["withCount comments"] --> G2["comments_count on Post"]
  end
Badge on the card vs loading the whole thread. withCount is the badge.

Step-by-Step Guide

Step 1: Prove the query count

Guessing wastes time. Hit the slow URL with Telescope Queries open. Debugbar works. So does a dirty DB::listen(fn ($q) => logger($q->sql)); if you are stuck without those.

flowchart LR
  Hit[Hit /posts index] --> Log[Open Telescope Queries]
  Log --> Pattern{"Same SQL repeating with different post_id?"}
  Pattern -->|Yes| N1[You have N+1]
  Pattern -->|No| Other[Look for slow join / missing index]
Repeating relation SQL with a new id each line = N+1. One ugly 800ms join is a different ticket.

Write the number down. Something like “20 posts, 61 queries.” If you skip that, you will swear you fixed it and never re-check.

Step 2: Eager-load on the root query

The listing query is the place. Controller, invokable, a scopeIndex() — I do not care, as long as it is not the Blade loop.

flowchart TB
  subgraph before [Before]
    C1[Controller: Post::latest get] --> L1[Loop / serializer]
    L1 --> R1["Touch author, tags, comments"]
  end
  subgraph after [After]
    C2["Controller: Post::with author tags + withCount comments"] --> L2[Loop / serializer]
    L2 --> R2[Relations already loaded]
  end
Serializer can keep touching $this->author. The query has to have loaded it already.
// Before — looks innocent, scales badly
$posts = Post::latest()->paginate(20);

// After — relations declared up front
$posts = Post::query()
    ->with(['author', 'tags'])
    ->withCount('comments')
    ->latest()
    ->paginate(20);

Need the comment author’s name too? Dot path:

Post::with(['comments.author', 'tags'])->paginate(20);
flowchart LR
  P[posts] --> C[comments]
  C --> A[users as author]
  P --> T[tags]
comments.author means load comments, then the author on those comments. Miss the nested bit and you N+1 the users table next.

Sanity check: bump per_page from 10 to 50. Query count should barely move. If it climbs, you still have a lazy relation — usually one you added to the Resource last Thursday.

Step 3: Guard so it cannot sneak back

Local/staging only:

// AppServiceProvider::boot() — local only
Model::preventLazyLoading(! app()->isProduction());

Forget with('tags') and you get LazyLoadingViolationException instead of a silent production bill. I still list with() / withCount() on the same lines as paginate() so the next person sees the contract.

If you want to be mean in tests, trigger a lazy load on purpose once and assert it throws. Then leave the real index green with eager loads in place.

Real-World Example

Laravel 11 API, Nuxt admin, GET /api/posts. Payload: title, author name, tag names, comment count.

return PostResource::collection(Post::latest()->paginate(50));

Resource:

'author' => $this->author->name,
'tags' => $this->tags->pluck('name'),
'comments_count' => $this->comments->count(),

Local fixture had a dozen posts. Nobody opened Telescope. Production paginated 50. Gateway timeout. Telescope was almost funny: posts once, then users / pivot / comments × 50.

sequenceDiagram
  participant Nuxt
  participant API
  participant DB
  Nuxt->>API: GET /api/posts?page=1
  API->>DB: SELECT * FROM posts LIMIT 50
  loop For each of 50 posts
    API->>DB: SELECT * FROM users WHERE id = ?
    API->>DB: SELECT tags via post_tag
    API->>DB: SELECT * FROM comments WHERE post_id = ?
  end
  Note over API,DB: ~1 + 150 queries. Timeout under load.
Three relations per row, fifty rows. You do the math. I did it after the timeout.
$posts = Post::query()
    ->with(['author', 'tags'])
    ->withCount('comments')
    ->latest()
    ->paginate(50);

// resource:
'comments_count' => $this->comments_count,
sequenceDiagram
  participant Nuxt
  participant API
  participant DB
  Nuxt->>API: GET /api/posts?page=1
  API->>DB: SELECT * FROM posts LIMIT 50
  API->>DB: SELECT * FROM users WHERE id IN (…)
  API->>DB: SELECT post_tag / tags for those posts
  API->>DB: aggregate comments_count per post
  Note over API,DB: Query count stays flat when page size grows.
Same JSON shape. comments_count is an attribute now, not a loaded collection.

Same PR had a second bug that looked like N+1 but was not: author_id on posts, belongsTo(User::class) with no extra argument. SQL wanted user_id. Authors came back null. Eager loading will happily load nothing. Pass the key.

Pros & Cons

Advantages

  • Once you know which keys the Resource touches, the fix is usually one with() line.
  • Telescope makes the pattern obvious. You are not profiling vibes.
  • preventLazyLoading() is annoying in local and that is the point.

Disadvantages

  • with('everything') on every query is how you over-fetch. Load what this response uses.
  • a.b.c.d still hurts. At some point a dedicated query or a cached fragment is less cute and faster.
  • Nothing in PHP forces this. Ship without Telescope and you will rediscover it in prod.

Best Practices

  • Keep with() / withCount() next to the paginate() that feeds the Resource. Future you will not hunt two files.
  • Number on the card → withCount. Full nested objects → with.
  • After you add a field to an API Resource, open Telescope again. That field is how N+1 comes back.
  • preventLazyLoading() outside production. Production should already have the loads; the guard is a seatbelt for the next PR.
  • Conditional includes: either with() only what the request asked for, or whenLoaded in the Resource so a missing eager load fails loudly instead of lazy-loading.

Common Mistakes

  • foreach ($posts as $p) { $p->load('tags'); } — still N queries. Load the collection once: $posts->load('tags'), or with() up front.
  • $this->comments->count() in a Resource. That pulls every comment. withCount('comments'), then $this->comments_count.
  • Relation method is author(), you wrote with('user'). Eloquent will not guess. Match the method name.
  • You added a join and assumed you were done, then the Resource still lazy-loads tags. Check the log anyway.
  • $post->comments->where('approved', true) filters in memory after a full load. Constrain the query: $post->comments()->where('approved', true)->get(), or with(['comments' => fn ($q) => $q->where('approved', true)]).

Frequently Asked Questions

What is the Laravel N+1 query problem?

One query loads the list. Each row then triggers another query for a relation. Total is about 1+N, or 1+N times how many relations you touch. with() / withCount() load those rows in bulk so the count stays small.

How do I detect N+1 in Laravel?

Telescope on the slow page. Same relation SQL, different parent ids, over and over. Raise per_page. If queries climb with rows, you are not done.

N+1 vs a slow join?

N+1 is many small queries that scale with rows — eager load. A slow join is one heavy query — indexes, fewer columns, sometimes raw SQL. Mixing them up wastes a day.

Is fixing N+1 worth it?

On any listing that touches relations, yes. Local seed data hides it. Production page size does not. I have seen a five-minute with() remove timeouts people were blaming on the server.

What is the difference between with() and load()?

with() hangs off the query builder before models exist. load() is for a collection you already have. Both are fine. load() inside a per-item loop is not.

Summary

N+1 is lazy relations in a loop. Telescope repeats the SQL. The fix sits on the listing query: with(), withCount(), nested dots if you need them. Local preventLazyLoading() stops the next PR from undoing it quietly.

Pick the fattest index you have, note the query count, add the missing loads, raise per_page, confirm the count stays boring.

Related: Laravel Eloquent Relationships.

Key Takeaways

  • N+1 = one parent query plus one relation query per row (times each relation you touch).
  • Telescope repeating the same SQL with a new id is the tell. Do not argue with the log.
  • Fix on the root query. withCount when you only need a number.
  • Nested data uses dots: with(['comments.author']).
  • preventLazyLoading() in local is louder than a production timeout.

Comments

0 comments · new ones appear after approval

No comments yet. Be the first to share your thoughts.

Leave a comment

Your comment will be reviewed before it appears.