The N+1 problem is the classic slow leak of Laravel performance. Nothing breaks. Tests stay green. Then someone opens the admin panel, the page loads 240 queries deep, and Postgres starts filing complaints.
Make lazy loading loud
The single highest-leverage line you can add to AppServiceProvider: prevent lazy loading outside production. Any relation accessed without eager loading now throws immediately — in tests, in local dev, the moment the pattern appears.
<pre><code class="language-php">public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}</code></pre>
Fixing it properly
Eager loading fixes the symptom. But the deeper fix is deciding where the query belongs. If a view always needs authors, the repository or controller should own that decision — not each template, hoping the caller remembered. Scopes make this reusable and testable:
<pre><code class="language-php">$posts = Post::query()
->latestPublished()
->with(['author:id,name', 'tags:posts.id,label'])
->paginate();</code></pre>
When eager loading isn't enough
Sometimes you need one aggregate per parent row — latest order per customer, unread count per conversation. Eager loading the entire collection wastes memory; subquery selections fetch exactly one value per row in the original query:
<pre><code class="language-php">return $query->addSelect([
'latest_message_at' => Message::select('created_at')
->whereColumn('conversation_id', 'conversations.id')
->latest()
->limit(1),
]);</code></pre>
A query you can't see is a query you can't fix. Observability first, optimisation second.
Keeping it fixed
Tests assert behaviour, so add one that asserts queries. Laravel's assertDatabaseQueryCount-style assertions via DB::listen or strict-mode listeners catch regressions the moment they land. Pair that with a staging profile running pg_stat_statements, and N+1 stops being a mystery and becomes a dashboard number that should never move.
- preventLazyLoading(!app()->isProduction()) — day one
- withCount() over loading collections just to count them
- Chunk large exports; cursor() for memory-bound iteration
- Index the foreign keys your new eager loads rely on