One innocent foreach loop can silently turn 1 query into 500. Here’s how to detect N+1 queries with Telescope and Debugbar, fix them with eager loading, and write a test that makes sure they never come back.
You write a clean, readable controller. You loop over a collection. You access a relationship on each model. The template renders. Everything looks fine.
Meanwhile, Laravel just fired 201 database queries to render a page that could have needed 2.
The N+1 query problem is the most common performance issue in Laravel applications — and uniquely dangerous because it’s completely invisible unless you’re actively looking for it. The page renders correctly. The data is right. The only symptom is slowness that you might attribute to the server, the network, or the complexity of the feature. It’s almost never any of those things.
What the N+1 Problem Actually Is
The name comes from the math. You make 1 query to fetch N records. Then you make another query for each of those N records to load a relationship. That’s 1 + N queries — which becomes 1 + 500 on a page that loads 500 orders.
// The innocent-looking code that causes the problem
$orders = Order::all(); // Query 1: SELECT * FROM orders
foreach ($orders as $order) {
echo $order->user->name;
// Query 2, 3, 4, ... N+1: SELECT * FROM users WHERE id = ?
// One query per order, every time through the loop
}
Every access to $order->user fires a new database query. Eloquent loads the relationship lazily by default — only when you ask for it, for each individual model. If there are 200 orders, you’ve just run 201 queries. If there are 500 orders, 501 queries. The number grows linearly with your dataset.
The fix is loading the relationship eagerly — before the loop, in a single query:
// The fixed version
$orders = Order::with('user')->get();
// Query 1: SELECT * FROM orders
// Query 2: SELECT * FROM users WHERE id IN (1, 2, 3, ..., 200)
foreach ($orders as $order) {
echo $order->user->name;
// No queries — user is already in memory
}
That’s the entire concept. But the implementation details — detecting it, fixing every variant, handling deeply nested relationships, and preventing it from coming back — that’s what this post is about.
Detecting N+1 Queries: The Tools
Laravel Telescope
Telescope is Laravel’s first-party debugging and introspection tool. It records every request, job, query, exception, and more — and crucially, it flags duplicate queries and shows you exactly which queries are being made for every request.
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
Once installed, visit /telescope and navigate to Queries. Look for:
- A large number of queries on a single request
- Multiple queries with the same structure but different
WHERE id = ?values - The
Duplicate Queriessection — Telescope highlights identical queries run multiple times
Telescope also shows you a Query Duration chart and groups queries by the request that triggered them. A request showing 150 queries where you expected 3 is your N+1.
Laravel Debugbar
Debugbar is the faster, browser-based alternative that shows query information directly in the page as a debug bar at the bottom.
composer require barryvdh/laravel-debugbar --dev
It auto-registers in local environments. The Database tab in the Debugbar shows:
- Total query count for the page
- Each query with its execution time
- Duplicate queries highlighted in orange
- The stack trace showing which line of code triggered each query
Clockwork
Clockwork integrates with browser DevTools and provides query analysis similar to Debugbar but through the browser’s native developer tools panel.
composer require itsgoingd/clockwork --dev
DB::listen — Manual Query Logging
For quick checks in development without installing a package, log all queries directly:
// In AppServiceProvider::boot() or a route
DB::listen(function ($query) {
Log::info('Query: ' . $query->sql, [
'bindings' => $query->bindings,
'time' => $query->time . 'ms',
]);
});
preventLazyLoading — The Best N+1 Prevention Tool
The most powerful prevention technique is Laravel’s preventLazyLoading() method. When enabled, it throws an exception the moment any relationship is accessed lazily — turning a silent performance problem into an immediate, impossible-to-miss error.
// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Throw an exception when any relationship is lazy-loaded
// Enable in local and testing environments
Model::preventLazyLoading(!app()->isProduction());
}
With this enabled, the innocent $order->user->name in a loop throws:
Illuminate\Database\LazyLoadingViolationException:
Attempted to lazy load [user] on model [App\Models\Order] but lazy loading is disabled.
You cannot ship an N+1 query when preventLazyLoading is enabled in your local environment. Every relationship access that isn’t eagerly loaded becomes a test failure or a local development error. This single setting, more than any code review or linting rule, will eliminate N+1 queries from your codebase.
Fixing N+1 Queries: Eager Loading
Basic with() — One Level Deep
// ✗ N+1 — user relationship loaded lazily
$orders = Order::all();
foreach ($orders as $order) {
echo $order->user->name; // query per order
echo $order->status->label; // another query per order
}
// ✓ Eager loaded — 3 queries total regardless of order count
$orders = Order::with(['user', 'status'])->get();
foreach ($orders as $order) {
echo $order->user->name; // in memory
echo $order->status->label; // in memory
}
Nested Relationships — Dot Notation
// Load nested relationships with dot notation
$orders = Order::with([
'user', // load user
'user.address', // load user's address too
'items', // load order items
'items.product', // load each item's product
'items.product.category', // load each product's category
])->get();
Constraining Eager Loads
You can add query constraints to eager loads without losing the performance benefit:
$users = User::with([
// Only load active subscriptions
'subscriptions' => fn($query) => $query->where('status', 'active'),
// Only load the 5 most recent orders
'orders' => fn($query) => $query->latest()->limit(5),
// Load the latest order with its items
'orders.items',
])->get();
withCount — Counting Without Loading
When you only need the count of a relationship (not the records themselves), withCount gives you a single aggregated query:
// ✗ N+1 — loads all comments to count them
$posts = Post::all();
foreach ($posts as $post) {
echo $post->comments->count(); // loads entire collection per post
}
// ✓ withCount — adds a comments_count column in a single query
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count; // no additional query
}
// Combine with constraints
$posts = Post::withCount([
'comments',
'comments as approved_comments_count' => fn($q) => $q->where('approved', true),
])->get();
withSum, withAvg, withMin, withMax — Aggregates
// Get the total value of each user's orders in one query
$users = User::withSum('orders', 'total')
->withAvg('orders', 'total')
->withCount('orders')
->get();
foreach ($users as $user) {
echo $user->orders_sum_total; // total order value
echo $user->orders_avg_total; // average order value
echo $user->orders_count; // number of orders
}
The loadMissing Pattern: Fixing N+1 After the Fact
Sometimes you receive a collection that was already fetched without eager loading — from a third-party package, a base controller, or legacy code you can’t easily change. loadMissing adds the eager load to already-fetched models:
// You receive $orders without the 'user' relationship loaded
public function processOrders(Collection $orders): void
{
// Load 'user' only if it hasn't been loaded already
$orders->loadMissing('user');
foreach ($orders as $order) {
// No more N+1 — user is now eagerly loaded
$this->sendConfirmation($order->user, $order);
}
}
Common N+1 Scenarios You Haven’t Noticed Yet
N+1 in Blade Templates
The most dangerous N+1 queries are the ones hiding in Blade templates, far from the controller where you’d think to look:
{{-- ✗ N+1 — each post fires a user query --}}
@foreach ($posts as $post)
<div>{{ $post->author->name }}</div> {{-- query per post --}}
<div>{{ $post->category->name }}</div> {{-- another query per post --}}
@foreach ($post->tags as $tag) {{-- another query per post --}}
<span>{{ $tag->name }}</span>
@endforeach
@endforeach
// Fix it in the controller — not in the template
$posts = Post::with(['author', 'category', 'tags'])->get();
N+1 in API Resources
// ✗ N+1 inside a Resource transform
class OrderResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'total' => $this->total,
'customer_name' => $this->user->name, // lazy load per order
'items_count' => $this->items->count(), // loads collection per order
'status_label' => $this->status->label, // lazy load per order
];
}
}
// The fix: eager load in the controller before passing to the resource
$orders = Order::with(['user', 'items', 'status'])->paginate(20);
return OrderResource::collection($orders);
N+1 in Polymorphic Relationships
Polymorphic relationships are particularly prone to N+1 because the related model type varies per record:
// Activities can belong to many different model types
$activities = Activity::all();
foreach ($activities as $activity) {
echo $activity->subject->title; // could be Post, Order, User — all separate queries
}
// Fix: eager load the polymorphic relationship
$activities = Activity::with('subject')->get();
N+1 in Computed Properties and Accessors
// ✗ Accessor that triggers a relationship load
class Order extends Model
{
// This accessor fires a query every time it's accessed
public function getDisplayNameAttribute(): string
{
return "Order #{$this->id} by {$this->user->name}";
}
}
// Every access to $order->display_name fires a user query
// Fix: ensure 'user' is always eager loaded when this attribute is used
Deeply Nested N+1 in Recursive Structures
// Category trees with parent/children — each level is an N+1
$categories = Category::all();
foreach ($categories as $category) {
foreach ($category->children as $child) { // N+1
foreach ($child->children as $grandchild) { // N+N queries
echo $grandchild->name;
}
}
}
// Fix for recursive structures
$categories = Category::with('children.children')->get();
// Or for arbitrary depth:
$categories = Category::with('allDescendants')->get(); // requires a recursive relationship
select() + with(): The Combination That Maximises Performance
Eager loading solves the query count problem. But if you’re selecting all columns on every model, you’re still pulling unnecessary data. Combine select() with with() for maximum efficiency:
// ✗ Fetches every column on every model
$orders = Order::with('user')->get();
// ✓ Only select what you actually display
$orders = Order::select(['id', 'total', 'status', 'created_at', 'user_id'])
->with(['user:id,name,email']) // colon syntax for relationship columns
->get();
Critical detail: When using
select()withwith(), always include the foreign key column (user_idin this case) in your select. Without it, Eloquent cannot match the eager-loaded records back to their parents — and the relationship will be null.
Writing a Test That Prevents N+1 From Coming Back
Detecting and fixing an N+1 query is only half the job. The other half is making sure it doesn’t silently reappear in a future pull request. The most reliable way to do that is a test.
Method 1: assertQueryCount (Laravel 10+)
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
class OrderListTest extends TestCase
{
use RefreshDatabase;
public function test_order_list_does_not_have_n_plus_1_queries(): void
{
// Create test data — enough records to make N+1 obvious
$users = User::factory()->count(10)->create();
$orders = Order::factory()
->count(10)
->recycle($users)
->create();
// Assert the entire request uses exactly this many queries
$this->assertQueryCount(2, function () {
$this->getJson('/api/orders')->assertOk();
});
// Query 1: SELECT * FROM orders
// Query 2: SELECT * FROM users WHERE id IN (...)
}
}
Method 2: DB::getQueryLog with Manual Count
public function test_loading_posts_with_authors_uses_two_queries(): void
{
Post::factory()->for(User::factory())->count(20)->create();
DB::enableQueryLog();
$response = $this->getJson('/api/posts');
$queries = DB::getQueryLog();
DB::disableQueryLog();
$this->assertCount(2, $queries,
"Expected 2 queries (posts + users), got " . count($queries) . ".\n" .
"Queries: " . collect($queries)->pluck('query')->implode("\n")
);
$response->assertOk();
}
Method 3: assertQueryCount with preventLazyLoading
Combining assertQueryCount with preventLazyLoading in tests gives you both the exact query count assertion AND automatic failures on any lazy-loaded relationship:
// tests/TestCase.php — base test class
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
// Any lazy-loaded relationship throws an exception in tests
Model::preventLazyLoading();
}
}
// Feature test — will fail if any relationship is lazily loaded
public function test_dashboard_page_is_efficient(): void
{
$user = User::factory()
->has(Order::factory()->count(15)->for(Product::factory()))
->create();
$this->actingAs($user);
$this->assertQueryCount(3, function () {
$this->get('/dashboard')->assertOk();
});
// If any lazy load occurs, preventLazyLoading throws first
// If query count changes, assertQueryCount fails second
// Both guards are active simultaneously
}
A Reusable Query Count Assertion
// For projects on Laravel < 10 or for more control
trait AssertsQueryCount
{
protected function assertQueryCount(int $expected, callable $callback, string $message = ''): void
{
DB::enableQueryLog();
DB::flushQueryLog();
$callback();
$queries = DB::getQueryLog();
$actual = count($queries);
DB::disableQueryLog();
$queryList = collect($queries)
->map(fn($q) => ' → ' . $q['query'])
->implode("\n");
$this->assertEquals(
$expected,
$actual,
$message ?: "Expected {$expected} queries but got {$actual}:\n{$queryList}"
);
}
}
Real-World Refactor: Before and After
Here is a typical controller refactor — the kind of change that takes ten minutes and eliminates hundreds of queries per request.
// ✗ Before — N+1 nightmare
class OrderController extends Controller
{
public function index(): View
{
$orders = Order::latest()->paginate(20);
return view('orders.index', compact('orders'));
}
}
{{-- ✗ Template triggers N+1 queries --}}
@foreach ($orders as $order)
<tr>
<td>{{ $order->user->name }}</td> {{-- query per order --}}
<td>{{ $order->user->email }}</td> {{-- cached, same model --}}
<td>{{ $order->status->label }}</td> {{-- query per order --}}
<td>{{ $order->items->count() }}</td> {{-- loads collection per order --}}
<td>{{ $order->items->sum('total') }}</td> {{-- same collection, cached --}}
<td>{{ $order->created_at->diffForHumans() }}</td>
</tr>
@endforeach
With 20 orders on the page:
- 1 query for orders
- 20 queries for users (one per order)
- 20 queries for statuses (one per order)
- 20 queries for items (one per order)
Total: 61 queries.
// ✓ After — 3 queries regardless of page size
class OrderController extends Controller
{
public function index(): View
{
$orders = Order::select(['id', 'user_id', 'status_id', 'created_at'])
->with([
'user:id,name,email',
'status:id,label',
])
->withCount('items')
->withSum('items', 'total')
->latest()
->paginate(20);
return view('orders.index', compact('orders'));
}
}
{{-- ✓ Template uses pre-loaded relationships --}}
@foreach ($orders as $order)
<tr>
<td>{{ $order->user->name }}</td>
<td>{{ $order->user->email }}</td>
<td>{{ $order->status->label }}</td>
<td>{{ $order->items_count }}</td> {{-- from withCount --}}
<td>{{ $order->items_sum_total }}</td> {{-- from withSum --}}
<td>{{ $order->created_at->diffForHumans() }}</td>
</tr>
@endforeach
Total: 3 queries. Pages that load 500ms faster. No user-facing changes. Ten minutes of work.
The N+1 Prevention Checklist
Add this to your code review process:
✓ Model::preventLazyLoading(!app()->isProduction()) in AppServiceProvider
✓ Laravel Telescope installed in development and staging
✓ Debugbar installed locally — query count visible on every page
✓ Every controller that returns collections uses with() for accessed relationships
✓ API Resources check: every accessed relationship is in the controller's with()
✓ Blade templates reviewed: no $model->relationship->property patterns without eager load
✓ withCount() used instead of $model->relationship->count()
✓ withSum/withAvg used instead of $model->relationship->sum()
✓ select() used to limit columns when fetching large datasets
✓ N+1 prevention test written for every endpoint that returns collections
✓ assertQueryCount used in feature tests for critical pages
✓ CI pipeline runs tests with preventLazyLoading enabled
Final Thoughts
The N+1 problem is embarrassingly common in Laravel applications precisely because Eloquent makes it so easy to write. The API is so clean and readable that the performance implications are genuinely invisible until you start measuring.
The solution is equally simple once you know it: eager load relationships with with(), use withCount and withSum for aggregations, and enable preventLazyLoading in development so the violations surface immediately.
But the most important thing in this post isn’t any particular technique — it’s the testing discipline. Fixing an N+1 query is a one-time act. Writing an assertQueryCount test means it can never silently come back. Add that test, and that endpoint’s query efficiency is enforced forever, in every pull request, in every deployment.
Install Telescope. Enable preventLazyLoading. Write the test. The query count won’t lie.
