Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 017 / 100

Fixing N+1 queries in Laravel: best practices explained

Eager loading in one line.

Expona Admin6 min read

The N+1 Query Problem in Laravel

If you've ever deployed a Laravel application only to watch database queries multiply unexpectedly, you've likely hit the N+1 problem. This silent performance killer happens when you loop through a collection and trigger a database query for each item, turning a simple list operation into dozens or hundreds of unnecessary round-trips to your database. Fixing N+1 queries in Laravel is one of the most impactful optimizations you'll make, and the good news is that Laravel gives us several straightforward tools to eliminate it entirely.

We'll walk through the best practices for preventing N+1 queries, from eager loading to development safeguards, so you catch these issues before they reach production.

Understanding the N+1 Problem

Let's start with a concrete example. Imagine you're fetching a list of orders and need to display the customer name for each:

php
// This triggers N+1 queries
$orders = Order::all(); // 1 query

foreach ($orders as $order) {
    echo $order->customer->name; // N queries (one per order)
}

With 100 orders, you've just hit your database 101 times. The first query fetches the orders. The remaining 100 queries fetch each customer individually because the customer relationship wasn't loaded upfront. This compounds quickly in real applications where you might be fetching customers, addresses, products, and payments all in one view.

The fix is simple in principle: tell Laravel to load relationships eagerly rather than lazily. In practice, making this a habit across your codebase takes discipline and the right monitoring tools.

Use with('items') When Loading Lists

The most direct solution is eager loading with the with() method. When you know you'll need a relationship, specify it when you fetch the parent model:

php
// Good: Only 2 queries total
$orders = Order::with('customer')->get();

foreach ($orders as $order) {
    echo $order->customer->name; // No additional queries
}

Laravel loads all customers in a single query, then hydrates the relationship. You've reduced 101 queries to 2.

For nested relationships, chain them together:

php
$orders = Order::with('customer.address', 'items.product', 'payment')
    ->get();

This loads customers and their addresses, order items and their products, and payments all in separate efficient queries. The key insight is that Laravel batches these by relationship, not by result.

You can also use arrow notation in newer Laravel versions for a fluent style:

php
$orders = Order::with(['customer' => fn($query) => $query->select('id', 'name')])
    ->get();

Here we're also constraining the customer query to only fetch the columns we need, reducing payload size.

In real-world scenarios, this pattern becomes your baseline. Any time you're fetching a collection that will be rendered in a template or API response, you should think: "What relationships will I access?" and eager load them explicitly. This turns a performance bug into a solved problem at query time.

Prevent Lazy Loading in Development with preventLazyLoading()

The best defense is catching N+1 bugs during development, not in production. Laravel provides preventLazyLoading() to throw an exception whenever code tries to lazy-load a relationship:

php
// In AppServiceProvider or a middleware
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    if ($this->app->isLocal()) {
        Model::preventLazyLoading();
    }
}

Now, if you forget to eager load a relationship, you'll get an exception instead of a silent performance issue:

php
$order = Order::first(); // No with() call
echo $order->customer->name; // Throws LazyLoadingViolationException

This is invaluable during development and testing. You'll quickly train yourself and your team to think about relationships upfront.

You can also allow specific relationships to be lazy-loaded if you have a legitimate reason (rare, but it happens):

php
Model::preventLazyLoading(!app()->isProduction());
// Or for specific models:
Order::preventLazyLoading();

The exception message tells you exactly which model and relationship triggered the violation, making debugging straightforward. We run this in all development environments and in staging. It's one of the highest-ROI debugging tools you can enable.

Laravel Debugbar Shows Query Counts

While preventLazyLoading() stops lazy loading, the Laravel Debugbar gives you visibility into what's actually happening. Install it in development:

php
composer require --dev barryvdh/laravel-debugbar

The debugbar appears at the bottom of your browser window during development, showing you the exact queries executed and their execution time:

View What it shows
Queries Each SQL statement, bindings, execution time, and stack trace
Summary Total query count and time
Timeline When queries executed relative to request lifecycle

This visual feedback is powerful. You immediately see when a simple page load triggers 50 queries instead of 5. Click any query to see its full SQL and the code that triggered it.

For API development, use the debugbar controller response header to see query counts in your API client:

php
// The debugbar sends query info in response headers
// Tools like Postman can display this

Combine this with preventLazyLoading() and you have a two-layer defense: exceptions catch lazy loading, debugbar shows you the overall performance picture. Over time, you develop an intuition for when relationships are needed and grab them upfront.

Use load() for Already-Fetched Models

Sometimes you've already fetched a model without eager loading, and only later realize you need a relationship. Rather than refetching from the database, use load():

php
$order = Order::first(); // Oops, forgot eager loading

// Load the relationship after the fact
$order->load('customer', 'items');

echo $order->customer->name; // No additional query

This is useful in controllers when you've fetched a model conditionally or when relationships are determined by request parameters:

php
public function show(Order $order)
{
    // The order is already fetched by route model binding
    
    if (request('include_items')) {
        $order->load('items.product');
    }
    
    if (request('include_customer')) {
        $order->load('customer');
    }
    
    return response()->json($order);
}

For collections, use loadMissing() to load a relationship only if it hasn't been loaded already:

php
$orders = Order::with('customer')->get();

// This won't reload customer since it's already loaded
$orders->loadMissing('customer');

// But this will load items if they weren't already loaded
$orders->loadMissing('items');

This is particularly useful in shared utility functions or base queries where you can't always know what's been loaded upstream.

Common Mistakes

We see several patterns that break eager loading strategies:

Forgetting nested relationships: You eager load with('customer') but access $order->customer->address without loading address. Be explicit about depth:

php
// Wrong
Order::with('customer')->get();

// Right
Order::with('customer.address')->get();

Eager loading inside loops: A common refactoring mistake is eager loading inside a loop, which defeats the purpose:

php
// Anti-pattern
$orders = Order::get();
foreach ($orders as $order) {
    $order->load('customer'); // Defeats eager loading
}

Not constraining queries: When you select only certain columns, ensure the ID is included so relationships work:

php
// Wrong - can't join without ID
Order::select('order_number', 'total')->with('customer')->get();

// Right
Order::select('id', 'order_number', 'total')->with('customer')->get();

Ignoring performance in pagination: Pagination doesn't exempt you from N+1 issues:

php
// Good
Order::with('customer')->paginate(15);

// Not
Order::paginate(15); // N+1 on each page

Key Takeaways

  • Always use with() when fetching collections that you'll access relationships on—this is your primary defense.
  • Enable preventLazyLoading() in development to catch lazy-loading mistakes immediately instead of in production.
  • Use Laravel Debugbar to visualize query counts and execution time during development; this builds intuition for efficient queries.
  • Reach for load() or loadMissing() when you've already fetched a model and need to add relationships after the fact.
  • Be explicit about nested relationships—chain them with dot notation to load multiple levels at once.
  • Include ID columns when selecting specific fields, or relationships won't hydrate correctly.
  • Make eager loading a habit during development, and N+1 queries become a rare occurrence rather than a production fire.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles