The N+1 query problem is the most common performance issue in Laravel applications, and it's the easiest to introduce accidentally. A developer adds $post->author->name in a Blade template, and suddenly a page that loaded 10 posts now executes 11 database queries instead of 2. Scale that to 100 posts and you have 101 queries. Scale to a thousand and your database server is on fire.
What makes N+1 problems particularly insidious is that they're invisible during development. Your local database with 20 records responds instantly whether you execute 1 query or 100. The problem only becomes apparent in production where tables have millions of rows, query latency is higher, and database connections are shared across hundreds of concurrent requests.
This post covers everything you need to detect N+1 queries, prevent them from being introduced, and monitor for them in production Laravel applications.
Understanding the N+1 Problem
An N+1 query occurs when you load a collection of N models and then access a relationship on each model individually, triggering a separate query for each one.
Here's the classic example:
// Controller
$posts = Post::all(); // 1 query: SELECT * FROM posts
// Blade template
@foreach($posts as $post)
{{ $post->author->name }} // N queries: SELECT * FROM users WHERE id = ?
@endforeach
The first query loads all posts. Then, for each post, Laravel lazy-loads the author relationship with a separate query. With 50 posts, that's 51 queries total.
The fix is eager loading — tell Laravel to load all authors in a single query upfront:
$posts = Post::with('author')->get(); // 2 queries total
// Query 1: SELECT * FROM posts
// Query 2: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
Two queries instead of 51. The concept is simple. The challenge is applying it consistently across a growing codebase where relationships are accessed in controllers, views, components, API resources, and queued jobs.
Prevention: Laravel's preventLazyLoading
Laravel provides a built-in mechanism to catch N+1 problems during development. When enabled, any lazy-loaded relationship throws an exception:
// In a service provider's boot method
use Illuminate\Database\Eloquent\Model;
Model::preventLazyLoading(! app()->isProduction());
Now the $post->author->name call without eager loading throws a LazyLoadingViolationException instead of silently executing an extra query. This forces developers to explicitly eager-load every relationship they access.
Handling Violations in Production
You don't want exceptions crashing pages in production. Instead, log violations so you can fix them without disrupting users:
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
logger()->warning("N+1 detected: lazy loading [{$relation}] on [" . get_class($model) . "]");
});
This approach gives you the best of both worlds: hard failures in development that force immediate fixes, and silent logging in production that reveals problems you missed.
When preventLazyLoading Gets in the Way
There are legitimate cases where lazy loading is acceptable:
- Artisan commands that process a single model and access one relationship
- Queue jobs that operate on a known small dataset
- Tinker sessions during debugging
You can selectively allow lazy loading on specific models:
// On a specific model
class Setting extends Model
{
// Settings table is tiny; lazy loading is fine
public $preventLazyLoading = false;
}
Or disable it temporarily in specific contexts:
Model::withoutPreventing(function () {
// Lazy loading is allowed in this closure
$user->profile->bio;
});
Eager Loading Patterns
Basic Eager Loading
The with() method accepts a single relationship or an array:
$posts = Post::with(['author', 'tags', 'comments'])->get();
Nested Eager Loading
Load relationships of relationships using dot notation:
$posts = Post::with(['comments.author', 'author.profile'])->get();
// Loads: posts, comments, comment authors, post authors, author profiles
Constrained Eager Loading
Filter or limit the eager-loaded relationship:
$posts = Post::with(['comments' => function ($query) {
$query->where('approved', true)
->latest()
->limit(5);
}])->get();
Laravel 12 supports limit() on eager loads natively — no external packages needed.
Conditional Eager Loading
Load relationships only when certain conditions are met:
$posts = Post::query()
->when($includeComments, fn ($query) => $query->with('comments'))
->when($includeAuthor, fn ($query) => $query->with('author'))
->get();
Eager Loading on Existing Collections
If you already have a collection and need to load a relationship after the fact:
$posts = Post::all();
// Later, you realize you need authors
$posts->load('author');
// Or load only if not already loaded
$posts->loadMissing('author');
loadMissing() is particularly useful in deep call stacks where you're not sure if a relationship was already eager-loaded upstream.
Default Eager Loading on Models
If a relationship is always needed when a model is loaded, define it as a default:
class Post extends Model
{
protected $with = ['author'];
}
Use this sparingly. It means every Post::all(), Post::find(), and Post::where(...) call loads the author relationship, even when it's not needed. This can turn a simple count query into a heavy join operation.
A better approach is to define scopes for common access patterns:
class Post extends Model
{
public function scopeWithFeedRelations(Builder $query): Builder
{
return $query->with(['author', 'tags', 'comments' => function ($query) {
$query->latest()->limit(3);
}]);
}
}
// Usage
$posts = Post::withFeedRelations()->latest()->paginate(20);
Detecting N+1 in Existing Code
Query Counting in Tests
Write tests that enforce query budgets:
it('loads the post index without N+1 queries', function () {
$author = User::factory()->create();
Post::factory()->count(20)->for($author, 'author')->create();
$queryCount = 0;
DB::listen(function () use (&$queryCount) {
$queryCount++;
});
$this->actingAs($author)
->get('/posts')
->assertOk();
expect($queryCount)->toBeLessThan(10);
});
If loading 20 posts requires more than 10 queries, something is lazy loading when it shouldn't be. This test catches regressions when someone adds a new relationship access in the view without updating the controller's eager loading.
Using Laravel Debugbar
Debugbar shows duplicate queries in its queries tab. If you see SELECT * FROM users WHERE id = ? repeated 20 times with different IDs, that's an N+1 problem on the author relationship.
Debugbar also shows the file and line where each query was triggered. This makes it trivial to trace the lazy load back to the specific Blade template line or component that caused it.
Using Telescope's Query Tab
Telescope groups queries by request. Look for requests with high query counts — anything above 20-30 queries per request warrants investigation. Sort by query count to find the worst offenders first.
Common N+1 Traps
Trap 1: Blade Components That Access Relationships
// app/View/Components/PostCard.php
public function render(): View
{
return view('components.post-card');
}
// components/post-card.blade.php
## {{ $post->title }}
by {{ $post->author->name }} {{-- N+1 if author not eager-loaded --}}
The component looks harmless. The N+1 is hidden because the relationship access happens in the view, far from the controller that loaded the posts.
Fix: Ensure the parent view or controller eager-loads the relationship:
$posts = Post::with('author')->paginate(20);
Trap 2: API Resources with Nested Relationships
class PostResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => new UserResource($this->author), // N+1
'comments_count' => $this->comments->count(), // N+1 AND loads all comments
];
}
}
Fix: Eager-load in the controller and use withCount for counts:
$posts = Post::with('author')->withCount('comments')->paginate(20);
// In the resource:
'comments_count' => $this->comments_count, // Uses the aggregate, no extra query
Trap 3: Accessors That Touch Relationships
class Order extends Model
{
protected function totalWithTax(): Attribute
{
return Attribute::get(fn () => $this->total * (1 + $this->taxRate->rate));
// Accessing $this->taxRate triggers a lazy load
}
}
Accessors are called during serialization, in Blade templates, and in API resources. Each call triggers the relationship query.
Fix: Eager-load taxRate wherever you use this accessor, or cache the relationship:
protected function totalWithTax(): Attribute
{
return Attribute::get(function () {
$rate = $this->relationLoaded('taxRate')
? $this->taxRate
: $this->taxRate()->first();
return $this->total * (1 + $rate->rate);
});
}
Trap 4: Queued Jobs Processing Collections
class SendWeeklyDigest implements ShouldQueue
{
public function handle(): void
{
User::where('digest_enabled', true)->chunk(100, function ($users) {
foreach ($users as $user) {
// Each of these triggers a query
$recentPosts = $user->subscriptions->flatMap->posts;
Mail::to($user)->send(new WeeklyDigest($recentPosts));
}
});
}
}
Fix:
User::where('digest_enabled', true)
->with(['subscriptions.posts' => function ($query) {
$query->where('created_at', '>', now()->subWeek());
}])
->chunk(100, function ($users) {
foreach ($users as $user) {
$recentPosts = $user->subscriptions->flatMap->posts;
Mail::to($user)->send(new WeeklyDigest($recentPosts));
}
});
Monitoring in Production
Even with preventLazyLoading and test coverage, N+1 problems can slip through. Monitor query counts in production using multiple layers.
Laravel Pulse
Pulse tracks slow queries and high-frequency queries in production. A query that appears hundreds of times per minute with slight parameter variations is likely an N+1 problem. Check Pulse's slow queries dashboard regularly.
Deploynix Server Monitoring
On Deploynix, monitor your database server's CPU and connection count. A sudden increase in active connections often correlates with N+1 queries — each lazy-loaded query opens and closes a connection (or holds one from the pool). If your connection count spikes during peak traffic, investigate the most active endpoints for N+1 issues.
Deploynix's real-time monitoring dashboard shows MySQL process CPU usage alongside your application server's metrics. When the database CPU spikes but the application CPU stays flat, the cause is almost always excessive queries — and N+1 is the most likely culprit.
Custom Middleware for Query Counting
Add middleware that logs requests exceeding a query threshold:
class QueryCountMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$queryCount = 0;
DB::listen(function () use (&$queryCount) {
$queryCount++;
});
$response = $next($request);
if ($queryCount > 30) {
logger()->warning("High query count: {$queryCount} queries", [
'url' => $request->fullUrl(),
'method' => $request->method(),
]);
}
return $response;
}
}
Register it globally in development or for specific route groups in production.
Conclusion
N+1 queries are the single most impactful performance problem in Laravel applications, and they're entirely preventable. Enable preventLazyLoading to catch them during development. Write query-count tests to prevent regressions. Use with(), loadMissing(), and withCount() consistently.
In production, monitor query patterns through Pulse and watch for database CPU spikes on your Deploynix dashboard. The combination of prevention at the code level and detection at the infrastructure level means N+1 problems get caught before they become outages.
The goal isn't zero lazy loading — it's intentional lazy loading. Every relationship access should be a deliberate choice, not an accidental database round trip hiding in a Blade component three levels deep.
Top comments (0)