Skip to main content

Cloudsoft Zone Build, deploy and scale with practical guides to PHP, Node.js, React, DevOps and AI automation. Explore the guides

Cloudsoft Zone
LaravelMySQLPHPTroubleshooting guide

How to find and fix N+1 queries in Laravel Eloquent

N+1 queries make Laravel pages slow as data grows. Learn to detect them with preventLazyLoading and query listeners, then fix them with eager loading, withCount and Laravel 13's automatic eager loading.

Cloudsoft Zone

Updated 8 min read

Blue plush elePHPant toy, the PHP community mascot, on a plain background
Photograph: Atomic Taco from Seattle, WA, USA / CC BY-SA 2.0, via Wikimedia Commons
In this article

A Laravel page that loads in 80 milliseconds with ten test records can take several seconds once real data arrives. One of the most common causes is the N+1 query problem: the code runs one query to fetch a list, then one more query for every item in that list to fetch related data. Eloquent makes this easy to do by accident, because accessing $post->author looks like reading a property, not running a query.

This article shows how to recognise N+1 queries in a Laravel 13 application, how to find them with tools the framework already includes, and how to fix them with eager loading, relationship counts and Laravel's automatic eager loading. All methods shown are documented in the Laravel 13.x Eloquent and database documentation, read on 7 October 2026.

What an N+1 query looks like

Take a Book model that belongs to an Author:

PHP
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Book extends Model
{
    public function author(): BelongsTo
    {
        return $this->belongsTo(Author::class);
    }
}

Now list every book with its author's name:

PHP
$books = Book::all();

foreach ($books as $book) {
    echo $book->author->name;
}

Relationships accessed as properties are lazy loaded: the related model is fetched the first time you touch it. As the Laravel documentation explains, with 25 books this loop runs 26 queries: one for the books and 25 for the authors. With 500 books it runs 501. Each query may be fast, but the round trips add up, and the database server does the same small piece of work hundreds of times.

The same pattern hides in Blade templates (echoing $book->author->name inside a @foreach loop), in API resources that include relationships, and in queued jobs that loop over records.

Entity-relationship diagram of a database schema with tables linked by foreign key lines
Every relationship line in a schema is a place where a loop can trigger one query per row.

Step 1: Find the N+1 queries

Make lazy loading fail loudly in development

The most effective tool is built in. Call Model::preventLazyLoading() in the boot method of AppServiceProvider:

PHP
<?php

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Model::preventLazyLoading(! $this->app->isProduction());
    }
}

Outside production, any attempt to lazy load a relationship now throws Illuminate\Database\LazyLoadingViolationException, naming the model and relationship. Your local browsing and your test suite will both point straight at the problem. Passing ! $this->app->isProduction() keeps production running normally if a lazy load slips through.

If you want visibility in production without exceptions, log violations instead:

PHP
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    $class = $model::class;

    info("Attempted to lazy load [{$relation}] on model [{$class}].");
});

Count and time queries

To see the actual SQL, register a listener with DB::listen() in a service provider. The QueryExecuted event gives you the SQL, bindings and execution time:

PHP
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::listen(function (QueryExecuted $query) {
    Log::debug($query->toRawSql(), ['ms' => $query->time]);
});

Enable this only locally or behind a flag; logging every query in production creates a lot of noise. Many teams instead use a development toolbar or Laravel Telescope locally to see query counts per request. If you see the same select * from authors where id = ? repeated with different IDs, you have found an N+1.

For production, DB::whenQueryingForLongerThan() calls your closure when a single request spends more than a given number of milliseconds in the database, which is a useful early warning:

PHP
use Illuminate\Database\Connection;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

DB::whenQueryingForLongerThan(500, function (Connection $connection, QueryExecuted $event) {
    Log::warning('Request spent over 500 ms in database queries.', [
        'connection' => $connection->getName(),
        'url' => request()->fullUrl(),
    ]);
});

Catch regressions in tests

Because preventLazyLoading is active in the testing environment with the setup above, any feature test that renders a page or API response will fail if a lazy load sneaks back in. That turns N+1 protection into a permanent guard rather than a one-off clean-up.

Step 2: Fix it with eager loading

Eager loading tells Eloquent to load the relationship for all parent models in one extra query. Use with():

PHP
$books = Book::with('author')->get();

foreach ($books as $book) {
    echo $book->author->name;
}

This runs two queries regardless of how many books there are:

SQL
select * from books
select * from authors where id in (1, 2, 3, 4, 5, ...)

Several and nested relationships

PHP
// Two relationships
$books = Book::with(['author', 'publisher'])->get();

// Nested: each author's contacts
$books = Book::with('author.contacts')->get();

Load only the columns you need

PHP
$books = Book::with('author:id,name')->get();

The documentation's rule: always include the id column and any foreign key columns the relationship needs, or Eloquent cannot match related models to their parents.

Constrain what is loaded

PHP
use App\Models\User;

$users = User::with(['posts' => function ($query) {
    $query->where('published', true)->orderBy('created_at', 'desc');
}])->get();

If you need users that have matching posts and also want those posts loaded, withWhereHas('posts', fn ($query) => ...) combines both in one call.

Load after the fact

When you already have a collection, for example because a relationship is needed only in some branches, use lazy eager loading:

PHP
$books = Book::all();

if ($showAuthors) {
    $books->load('author');
}

// Only loads if not already loaded
$book->loadMissing('author');
UML class diagram describing entities, attributes and relationships in an entity-relationship model
Eloquent relationships map onto these entity-relationship concepts, and eager loading fetches them in bulk.

Step 3: Count without loading

A frequent hidden N+1 is counting related records in a list, such as showing "12 comments" next to each post with $post->comments->count(). That loads every comment for every post. Use withCount() instead, which adds a {relation}_count attribute computed in SQL:

PHP
use App\Models\Post;

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;
}

Eloquent offers related aggregates in the same family, such as withSum, withAvg, withMin, withMax and withExists.

Step 4: Consider automatic eager loading

Laravel 13 can eager load relationships automatically when you access them on a model that came from a collection. Enable it globally in AppServiceProvider:

PHP
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::automaticallyEagerLoadRelationships();
}

Or for one collection only:

PHP
$users = User::where('vip', true)->get();

return $users->withRelationshipAutoloading();

With this enabled, the first time you access $user->posts on any user in the collection, Laravel loads posts for all users in that collection in one query, and the same happens one level down for comments. It is a good safety net for large codebases. Explicit with() calls still have advantages: they document intent, let you select columns and add constraints, and make the query pattern obvious to the next developer.

Decide how this combines with preventLazyLoading in your project and test it; the two features express different policies, so pick one primary approach and apply it consistently.

Step 5: Avoid the less obvious traps

TrapFix
API resources that include relationships for every itemEager load in the controller, and use whenLoaded() in the resource so missing relationships are skipped rather than lazy loaded
Accessors or appended attributes that touch relationshipsEager load the relationship wherever the attribute is serialised, or compute the value with withCount/withSum
$with on the model loading relationships you rarely needKeep $with small; use without() or withOnly() for queries that do not need them
Polymorphic relationshipsUse morphWith() or loadMorph() to eager load nested relationships per related type
Queued jobs looping over modelsEager load inside the job, and process large sets with chunkById() or lazyById()
Pagination pagesCall with() before paginate(), the same as before get()

A before-and-after example

Here is a typical controller for a blog index that shows each post's author, category and comment count:

PHP
// Before: 1 query for posts, then up to 3 more per post
$posts = Post::latest()->paginate(20);

// After: a fixed number of queries per page
$posts = Post::query()
    ->with(['author:id,name', 'category:id,name,slug'])
    ->withCount('comments')
    ->latest()
    ->paginate(20);

With 20 posts per page, the first version can run more than 60 queries if the template reads $post->author, $post->category and $post->comments->count(). The second runs the paginated posts query and its count query, one query each for authors and categories, and computes the comment count inside the posts query. The number stays the same whether the page shows 20 posts or 50. The column lists assume that posts holds the author_id and category_id foreign keys, so id is all that is needed on the related tables.

Troubleshooting checklist

  • ☐ Model::preventLazyLoading(! $this->app->isProduction()) is in AppServiceProvider.
  • ☐ Feature tests cover the main list pages and API endpoints, so violations fail CI.
  • ☐ Each list query names its relationships with with(), or automatic eager loading is enabled deliberately.
  • ☐ Counts and sums use withCount and related aggregates, not loaded collections.
  • ☐ Eager loads select only needed columns where tables are wide, including keys.
  • ☐ Slow-request monitoring with DB::whenQueryingForLongerThan() or an APM tool is in place in production.
  • ☐ Foreign key columns used by relationships are indexed in the database.

Summary

N+1 queries are easy to write in Eloquent and easy to fix once you can see them. Turn on preventLazyLoading outside production so every lazy load fails fast, use with(), load() and withCount() to fetch related data in a fixed number of queries, and consider automatic eager loading as a safety net. Add a slow-query alert in production, and keep your feature tests running with lazy loading disabled so the fix stays fixed.

Frequently asked questions

  • What is the N+1 query problem?

    Running one query to fetch a list and then one more query for each item to fetch related data. With 25 books and their authors, that is 26 queries instead of 2.

  • Should I enable preventLazyLoading in production?

    The Laravel docs suggest Model::preventLazyLoading(! $this->app->isProduction()), so it throws in development and tests but not in production. Use handleLazyLoadingViolationUsing() to log violations instead.

  • Is automatic eager loading a replacement for with()?

    It is a safety net. Explicit with() calls still let you choose columns and constraints and make queries easier to read.

  • How do I show counts without loading all related rows?

    Use withCount('comments'), which adds a comments_count attribute calculated in SQL.

Advertise on CloudSoftZone

Your next customer could start right here.

Introduce your tool, product or business to readers exploring technology.

Advertise with us

Explore placements and request a private quote

Sources

  1. Eloquent: Relationships (Laravel 13.x) — Laravel
  2. Eloquent: Getting Started (Laravel 13.x) — Laravel
  3. Database: Getting Started (Laravel 13.x) — Laravel
  4. Eloquent: API Resources (Laravel 13.x) — Laravel

Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.

Comments

No comments yet. Be the first to join the conversation.

Leave a comment

Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.

Never published. Used only to contact you about this comment.

Loading the security check…

Comments are moderated before publication.