Killing N+1 Queries — Making a Slow Laravel + PostgreSQL API Fast
The Smell
An endpoint feels slow, but every individual query looks instant. You open the query log and there it is: one request firing 200+ queries. Nine times out of ten, that's N+1 — and Eloquent's lazy loading makes it completely invisible until you go looking.
The classic shape:
$posts = Post::all(); // 1 query
foreach ($posts as $post) {
echo $post->author->name; // +1 query, every single iteration
}
100 posts = 1 + 100 = 101 queries. Each one is fast on its own, which is exactly why it hides — the cost is in the count, not the individual latency. Add network round-trips to a separate database server and that's where your seconds go.
Step 1 — Actually See It
You can't fix what you can't measure. The fastest inline check, no packages needed:
DB::enableQueryLog();
$data = MyController::buildResponse();
dd(count(DB::getQueryLog())); // 201? You've found your N+1.
For day-to-day work I lean on Laravel Telescope or Debugbar — both show the query count per request and flag duplicates. But the query log is enough to confirm the diagnosis in ten seconds.
Step 2 — Eager Load
The fix is to tell Eloquent what you'll need up front so it fetches relations in one batched query instead of one-per-row:
$posts = Post::with('author')->get();
Now it's 2 queries total — one for the posts, one WHERE id IN (...) for all the authors at once. From 101 to 2.
The variants I reach for constantly:
// Nested relations
Post::with('author.profile')->get();
// Multiple relations
Post::with(['author', 'comments'])->get();
// Constrained — only load what you'll show
Post::with(['comments' => fn ($q) => $q->where('approved', true)])->get();
// Just need a count? Don't hydrate thousands of models:
Post::withCount('comments')->get(); // → $post->comments_count
// Already have the collection? Lazy-eager-load onto it:
$posts->load('author');
Step 3 — Don't Over-Fetch
Eager loading kills the query count, but you can still move too much data. Pull only the columns the response actually uses:
Post::with('author:id,name')->get(['id', 'title', 'author_id']);
⚠️ Gotcha: when you select specific columns on a relation, you must include the join keys —
idon the author, andauthor_idon the post — or Eloquent has nothing to match on and the relation silently comes backnull.
Step 4 — Index the Foreign Key
Even perfectly eager-loaded, that WHERE author_id IN (...) still has to find rows. On a big table without an index, it's a sequential scan. Check it honestly with EXPLAIN ANALYZE:
EXPLAIN ANALYZE
SELECT * FROM comments WHERE post_id IN (1, 2, 3 /* … */);
See a Seq Scan on a large table? Add the index:
CREATE INDEX idx_comments_post_id ON comments (post_id);
This bites people coming from MySQL: PostgreSQL does not automatically index foreign keys. It indexes primary keys, but a column you reference with constrained() in a migration gets a constraint, not an index, unless you ask for one.
The Line That Stops It Coming Back
Fixing today's N+1 is easy. Stopping the next one is the real win. Laravel can make lazy loading throw outside production, so you catch it the moment you write it:
// AppServiceProvider::boot()
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
Now any un-eager-loaded relationship access in dev or staging blows up with a LazyLoadingViolationException — including the sneaky ones hiding inside accessors and API Resources, where a controller looks clean but toArray() quietly re-introduces the problem. (It's the same defensive instinct as prohibitDestructiveCommands from my staging near-miss post — let the framework enforce the rule so a tired human doesn't have to remember it.)
The Quick Checklist
| Symptom | Fix |
|---|---|
| Query count scales with row count | with() to eager load |
| Only need a number | withCount(), not the full relation |
| Loading columns you never use | Select specific columns (include join keys!) |
IN (...) query still slow | Index the foreign key in Postgres |
| N+1 keeps reappearing | Model::preventLazyLoading() outside prod |
The fastest query is the one you never send. Eager loading turns N+1 into 2 — and
preventLazyLoadingmakes sure it stays that way.