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 — id on the author, and author_id on the post — or Eloquent has nothing to match on and the relation silently comes back null.

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

SymptomFix
Query count scales with row countwith() to eager load
Only need a numberwithCount(), not the full relation
Loading columns you never useSelect specific columns (include join keys!)
IN (...) query still slowIndex the foreign key in Postgres
N+1 keeps reappearingModel::preventLazyLoading() outside prod

The fastest query is the one you never send. Eager loading turns N+1 into 2 — and preventLazyLoading makes sure it stays that way.