The N+1 Problem Is Still Killing Your App
Every Laravel developer knows what an N+1 query is. Far fewer have a systematic strategy for preventing them at the architectural level. This article goes beyond the basics — we'll look at detection tooling, subtle N+1 patterns that survive code review, and structural approaches that make the problem harder to introduce in the first place.
Detecting N+1 Queries in Development
The fastest feedback loop is DB::listen or a dedicated package. In a service provider or test bootstrap:
DB::listen(function (QueryExecuted $event) {
if (str_contains($event->sql, 'select')) {
logger()->debug($event->sql, ['time' => $event->time]);
}
});
For a more structured approach, Laravel Debugbar groups duplicate queries visually. In CI, use spatie/laravel-query-detector to fail tests when N+1 queries are detected automatically.
// In a Pest test
use Spatie\QueryDetector\QueryDetector;
it('loads posts without N+1', function () {
app(QueryDetector::class)->enable();
$posts = Post::with('author', 'tags')->get();
$posts->each(fn ($p) => $p->author->name);
expect(app(QueryDetector::class)->getDetectedQueries())->toBeEmpty();
});
The Obvious Fix: Eager Loading
You already know with(). The subtlety is when to apply it.
// Fragile: caller must remember to eager-load
$posts = Post::all();
// Better: scope it at the model level for common access patterns
class Post extends Model
{
protected $with = ['author']; // always eager-load author
}
Use $with sparingly — it runs on every query including ones that don't need the relation. Prefer explicit with() calls in repository or query builder methods.
Nested Eager Loading
$posts = Post::with([
'comments.author',
'tags',
'media' => fn ($q) => $q->where('type', 'cover'),
])->get();
Constrained eager loads let you filter the relation without triggering extra queries.
Subtle N+1 Patterns That Survive Review
Lazy Loading Inside Blade
{{-- This fires a query per post --}}
@foreach ($posts as $post)
{{ $post->author->name }}
@endforeach
Enable Model::preventLazyLoading() in AppServiceProvider for non-production environments:
Model::preventLazyLoading(! app()->isProduction());
This throws a LazyLoadingViolationException immediately, surfacing the problem during development.
Counting Relations
// N+1: fires a COUNT query per post
$posts->each(fn ($p) => $p->comments->count());
// Correct: one extra query total
$posts = Post::withCount('comments')->get();
// Access via $post->comments_count
Polymorphic Relations
Polymorphic morphTo relations are notorious. Use morphWith to eager-load the correct related types:
$activities = Activity::with('subject')->get();
// subject could be Post, Comment, or Video — each type fires its own query
// Better: constrain per type
$activities = Activity::with([
'subject' => fn ($morph) => $morph
->morphWith([
Post::class => ['author'],
Comment::class => ['post'],
]),
])->get();
Architectural Prevention
The most durable fix is structural. If your query logic lives in a dedicated class, you control the eager loads in one place:
final class PostListQuery
{
public function handle(int $perPage = 20): LengthAwarePaginator
{
return Post::query()
->with(['author', 'tags', 'media'])
->withCount('comments')
->latest()
->paginate($perPage);
}
}
Controllers and Filament resources call PostListQuery — they never build raw queries. Eager loading is a concern of the query object, not the caller.
Key Takeaways
- Enable
Model::preventLazyLoading()in development; it catches violations immediately. - Use
withCount()instead of loading full relation collections just to count them. - Constrain eager loads with closures to avoid over-fetching.
- Centralise query construction in dedicated query classes or repositories to enforce consistent eager loading.
- Add a Pest assertion using
spatie/laravel-query-detectorto your critical read paths in CI.