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.
Updated 8 min read

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
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:
$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.

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
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:
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:
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:
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():
$books = Book::with('author')->get();
foreach ($books as $book) {
echo $book->author->name;
}This runs two queries regardless of how many books there are:
select * from books
select * from authors where id in (1, 2, 3, 4, 5, ...)Several and nested relationships
// 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
$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
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:
$books = Book::all();
if ($showAuthors) {
$books->load('author');
}
// Only loads if not already loaded
$book->loadMissing('author');
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:
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:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::automaticallyEagerLoadRelationships();
}Or for one collection only:
$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
| Trap | Fix |
|---|---|
| API resources that include relationships for every item | Eager 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 relationships | Eager load the relationship wherever the attribute is serialised, or compute the value with withCount/withSum |
$with on the model loading relationships you rarely need | Keep $with small; use without() or withOnly() for queries that do not need them |
| Polymorphic relationships | Use morphWith() or loadMorph() to eager load nested relationships per related type |
| Queued jobs looping over models | Eager load inside the job, and process large sets with chunkById() or lazyById() |
| Pagination pages | Call 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:
// 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 inAppServiceProvider. - ☐ 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
withCountand 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. UsehandleLazyLoadingViolationUsing()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 acomments_countattribute calculated in SQL.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- Eloquent: Relationships (Laravel 13.x) — Laravel
- Eloquent: Getting Started (Laravel 13.x) — Laravel
- Database: Getting Started (Laravel 13.x) — Laravel
- 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.
Related tools
- Directory profile
GitHub
Git-based platform for hosting code, reviewing pull requests, tracking issues and running CI/CD workflows, with free and per-user paid plans.
A tool connected to a service selected for this article. Compare its fit for your project; inclusion is not an endorsement.
- Directory profile
Postman
API platform for designing, testing, documenting and monitoring APIs, with desktop and web apps, a CLI and a free plan.
A tool connected to a service selected for this article. Compare its fit for your project; inclusion is not an endorsement.
More from Cloudsoft Zone

Laravel
Upgrading from Laravel 12 to Laravel 13: a practical guide
Laravel 12 lost bug-fix support in August 2026. This guide covers the PHP 8.3 requirement, Composer changes, the CSRF middleware rename, cache and session hardening, low-impact changes that often bite, and a safe rollout plan.
By Cloudsoft Zone

ComparisonLaravel
Laravel Sanctum vs Passport: which API authentication package to use
Sanctum and Passport both authenticate Laravel APIs, but they solve different problems. This comparison explains tokens, SPA cookies, OAuth2 grants, expiry and clean-up in Laravel 13, and when each package is the right choice.
By Cloudsoft Zone

TutorialLaravel
Laravel AI SDK 1.0 tutorial: build your first AI agent in Laravel 13
Build a tested Laravel 13 AI agent with laravel/ai 1.x: providers, a PHP tool, structured output, queued jobs, fakes for tests, cost caps and a security checklist.
By Cloudsoft Zone
