6 min read

Stopping N+1 Queries in a Laravel App Without Rewriting the Whole Codebase

A practical approach to finding and fixing N+1 queries in real Laravel apps using query inspection, eager loading, and small refactors that improve performance without forcing a rewrite.
Feature image for Stopping N+1 Queries in a Laravel App Without Rewriting the Whole Codebase

One of the easiest ways for a Laravel app to get slow over time is not a dramatic architectural mistake. It is a quiet accumulation of N+1 queries.

A page works fine in development with a handful of records. Then production data grows, a dashboard starts loading 50 or 200 models at once, and suddenly a request that should run a few queries is running hundreds.

The frustrating part is that this usually does not come from one obviously bad file. It shows up in Blade views, API resources, accessors, policies, queued jobs, and “temporary” relationship calls added months ago.

The good news is that you usually do not need a rewrite to fix it. Most N+1 issues can be removed with a combination of:

  • query inspection
  • targeted eager loading
  • reducing hidden relationship access
  • a few careful refactors in high-traffic code paths

If you are dealing with a real production app, that is the path that matters.

What N+1 actually looks like in a real app

The classic example is simple: you load a list of posts, then for each post you load its author.

$posts = Post::latest()->take(50)->get();

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

That becomes:

  • 1 query to load posts
  • 50 more queries to load each author

That is the N+1 pattern.

But production cases are usually messier than this. Common examples include:

  • a Blade template calling $order->customer->company->name
  • an API resource checking $user->roles->contains(...)
  • an accessor like getIsOverdueAttribute() touching related models
  • a policy method loading permissions inside a loop
  • nested loops over comments, tags, or line items
  • Vue-facing API endpoints returning resources that trigger lazy loading during serialization

In other words, the problem is often not the initial query. It is everything that happens after the collection has already been loaded.

Start with visibility, not assumptions

Before fixing anything, inspect the queries.

In local development, Laravel Debugbar is a fast way to see query count and repeated statements. Telescope can also help if you already use it. Even plain query logging is enough when needed.

What you are looking for is not just “too many queries.” You are looking for repeated queries with different IDs.

For example, if you see this pattern repeated dozens of times:

select * from `users` where `users`.`id` = ? limit 1

that is a strong sign a relationship is being lazily loaded in a loop.

A practical workflow looks like this:

  1. Pick one slow page or endpoint.
  2. Load it with realistic data volume.
  3. Count the queries.
  4. Look for repeated relationship queries.
  5. Trace those queries back to the controller, resource, view, or accessor triggering them.

Do not try to optimize the entire app in one pass. Fix the endpoints that matter first.

The first fix: add eager loading where the data is actually needed

The most direct solution is eager loading with with().

$posts = Post::query()
    ->with('author')
    ->latest()
    ->take(50)
    ->get();

Now Laravel loads the posts and authors in two queries instead of fifty-one.

For nested relationships:

$orders = Order::query()
    ->with(['customer.company', 'lineItems.product'])
    ->latest()
    ->paginate(50);

This is often enough to remove the biggest source of slowness on a page.

A useful rule: eager load from the boundary of the request.

That usually means:

  • controller query
  • action class or service returning data to the controller
  • query object used by an API endpoint

Avoid scattering eager loading fixes deep inside unrelated code. If a page needs posts.author and posts.category, make that explicit at the query that builds the result set.

Be selective with columns

In older codebases, developers sometimes overcorrect and eager load everything. That replaces one problem with another: bigger result sets, more memory use, and slower hydration.

If you only need a few fields, constrain the selection.

$posts = Post::query()
    ->with(['author:id,name', 'category:id,title'])
    ->select('id', 'author_id', 'category_id', 'title', 'published_at')
    ->latest()
    ->get();

This matters on large collections and busy endpoints.

Just make sure the foreign keys needed for relationships are still selected.

Watch for hidden N+1 queries in resources and accessors

This is where many teams miss the real problem.

A controller may look clean:

$users = User::latest()->paginate();

return UserResource::collection($users);

But the resource can still trigger lazy loading:

public function toArray($request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'team' => $this->team->name,
        'roles' => $this->roles->pluck('name'),
    ];
}

If team and roles were not eager loaded, serialization causes the N+1 problem.

The fix is partly query-side:

$users = User::query()
    ->with(['team:id,name', 'roles:id,name'])
    ->latest()
    ->paginate();

And partly defensive inside the resource when relationships are optional for some endpoints:

public function toArray($request): array
{
    return [
        'id' => $this->id,
        'name' => $this->name,
        'team' => $this->whenLoaded('team', fn () => $this->team->name),
        'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')),
    ];
}

That makes the resource safer across different query contexts.

The same issue appears in accessors and appended attributes. If an accessor touches a relationship, it can quietly execute queries during serialization.

That does not mean never use accessors. It means be careful using relationship-dependent logic in attributes that get called for every model in a collection.

Use aggregates instead of loading full relationships when you only need counts or sums

A common waste pattern is loading a whole relationship just to compute a number.

foreach ($projects as $project) {
    echo $project->tasks->count();
}

Even if eager loaded, this can be heavier than needed.

Use aggregate helpers instead:

$projects = Project::query()
    ->withCount('tasks')
    ->get();

Then:

echo $project->tasks_count;

The same idea applies to sums and existence checks:

  • withCount()
  • withSum()
  • withExists()
  • withAvg()

These are often better than hydrating full related collections when the UI only needs summary data.

Fix nested loops before they spread

Nested loops make N+1 problems multiply fast.

Example:

$posts = Post::latest()->take(20)->get();

foreach ($posts as $post) {
    foreach ($post->comments as $comment) {
        echo $comment->author->name;
    }
}

Now you may have:

  • 1 query for posts
  • 20 queries for comments
  • many more queries for comment authors

The fix is not just with('comments'). You need the full graph actually used:

$posts = Post::query()
    ->with(['comments.author'])
    ->latest()
    ->take(20)
    ->get();

This is where query inspection matters. Guessing usually leads to partial fixes.

Prevent new lazy loading issues during development

One of Laravel’s most useful guardrails for growing apps is preventing lazy loading outside production.

In AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

Now when code accidentally triggers lazy loading in development or tests, Laravel can surface it early.

This does not solve existing production issues by itself, but it stops the codebase from quietly getting worse.

On teams maintaining messy apps, this is one of the highest-leverage changes you can make.

Refactor repeated relationship access into query-focused code

If the same model graph is needed in multiple places, do not keep patching it ad hoc.

Move that logic into a dedicated query method or action so the eager loading rules live in one place.

For example:

class ListOrdersForAdmin
{
    public function handle(): LengthAwarePaginator
    {
        return Order::query()
            ->with([
                'customer:id,name,company_id',
                'customer.company:id,name',
                'lineItems.product:id,name,sku',
            ])
            ->withCount('lineItems')
            ->latest()
            ->paginate(50);
    }
}

This is much easier to maintain than relying on each controller, resource, or view to remember what should be loaded.

It is not a big rewrite. It is just moving fragile query behavior closer to where it belongs.

Know when eager loading is not enough

Not every repeated query problem is solved with with().

Sometimes the real issue is that the page is trying to render too much data at once. Or a filter causes an expensive unindexed query. Or the resource shape is too large for the frontend.

If eager loading cuts query count but the endpoint is still slow, check:

  • total rows being returned
  • missing database indexes
  • unnecessary columns or huge JSON payloads
  • expensive accessors or appended attributes
  • pagination strategy
  • duplicate work across policies, transformers, or view composers

N+1 is common, but it is not the only performance issue in mature Laravel apps.

A practical order of operations for production apps

When I am fixing a slow Laravel endpoint in an existing codebase, this is usually the order:

  1. Reproduce the slow request with realistic data.
  2. Inspect query count and repeated SQL patterns.
  3. Add eager loading for the exact relationships being touched.
  4. Replace full relationship loading with withCount or other aggregates where possible.
  5. Remove relationship access from hot-path accessors or resources when it is not guaranteed to be loaded.
  6. Extract the final query into a dedicated method or action if the endpoint is important.
  7. Enable lazy loading prevention in non-production environments.

That sequence solves a surprising number of real problems without any major rewrite.

Final thought

N+1 queries are one of those issues that make a Laravel app feel fine right up until it does not. They hide in otherwise reasonable code, and they tend to show up after the app has already grown enough that changing everything feels risky.

That is exactly why targeted fixes matter.

You do not need to redesign the whole application to get meaningful performance gains. Start with one slow endpoint. Inspect the queries. Eager load what is actually used. Replace relationship-heavy summaries with aggregates. Then put a guardrail in place so the problem stops coming back.

In most production apps, that is the difference between a fragile slow page and something that feels under control again.

References