+1 (415) 599-8902

Fixing Slow Eloquent: N+1 Queries, Strict Mode, Indexes and Caching in Laravel 13

Almost every "the app is slow" ticket we get for an existing Laravel application turns out to be the database, and almost every database problem turns out to be one of four things: N+1 queries, a missing index, loading far more rows than the page needs, or doing in PHP what the database could do in one statement. None of them require exotic tooling to find. This tutorial is the routine we run on a Laravel 13 codebase in the first day of a performance engagement, in the order we run it.

Step 1: Make the framework tell you

Before you profile anything, turn on the guardrails Laravel already ships. In AppServiceProvider::boot():

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

public function boot(): void
{
    Model::shouldBeStrict(! $this->app->isProduction());

    if (! $this->app->isProduction()) {
        DB::listen(function ($query) {
            if ($query->time > 200) {
                logger()->warning('Slow query', [
                    'sql'  => $query->sql,
                    'time' => $query->time,
                ]);
            }
        });
    }
}

Model::shouldBeStrict() switches on three separate protections:

  • preventLazyLoading — accessing an un-eager-loaded relationship throws LazyLoadingViolationException. This is the N+1 detector, and it is better than any package because it fails at the exact line that caused it.
  • preventSilentlyDiscardingAttributes$model->fill(['not_fillable' => 1]) throws instead of quietly dropping the value.
  • preventAccessingMissingAttributes — reading a column you did not select() throws instead of returning null.

Run your test suite with strict mode on and you will usually collect a list of N+1 sites before you have opened a browser. Keep it off in production (or at least keep lazy-loading violations reported rather than thrown) so a missed case does not turn into a 500 for a customer:

Model::preventLazyLoading();

Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    report(new \RuntimeException(
        'Lazy loaded ['.$relation.'] on '.$model::class
    ));
});

Step 2: Measure, in this order

Laravel Pulse is the cheapest production-safe start. composer require laravel/pulse, publish, migrate, and you get Slow Queries, Slow Requests, Slow Jobs and Slow Outgoing Requests cards out of the box. It samples, so the overhead is small. Pulse tells you which endpoint and which query, which is 80% of the diagnosis.

Telescope in local and staging gives you the full request timeline: every query with its bindings and duration, every job, every cache hit. Its "Requests" entry with 312 queries attached is the classic N+1 screenshot.

Debugbar (barryvdh/laravel-debugbar) is still the fastest loop for a single page you are actively fixing — query count in the corner, refresh, watch the number drop.

EXPLAIN is the one people skip. Once you have the offending SQL, run it against a copy of production-sized data:

EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending' ORDER BY created_at DESC LIMIT 20;

A sequential scan over 4 million rows is a missing index, not slow Eloquent.

Do not tune anything against a 200-row local database. Row counts change the plan; a query that is instant on your laptop can be the one taking down the site.

Step 3: Kill the N+1s

The shape is always the same — a loop that touches a relationship:

$orders = Order::latest()->take(50)->get();

foreach ($orders as $order) {
    echo $order->customer->name;      // 1 query per order
}

51 queries. Eager load instead:

$orders = Order::with('customer')->latest()->take(50)->get();

Two queries. The variations worth knowing:

Nested and multiple relations.

Order::with(['customer', 'lines.product.brand'])->get();

Only the columns you need. Always include the key the relationship joins on, or the match silently fails:

Order::with('customer:id,name,email')->get();

Counts instead of collections. If the view only prints a number, do not hydrate the models:

Post::withCount('comments')->get();     // $post->comments_count
Post::withExists('comments')->get();    // $post->comments_exists

Aggregates. withSum, withAvg, withMax do the same for totals — an invoice list with withSum('lines', 'amount') beats loading every line.

Latest-of-many. For "show each user's most recent order", a HasOne relationship with latestOfMany() plus with() avoids both the N+1 and the correlated subquery:

public function latestOrder(): HasOne
{
    return $this->hasOne(Order::class)->latestOfMany();
}

Conditional loading. load() after the fact is fine when the branch is genuine; loadMissing() is safer inside components and accessors that may be reached twice.

Polymorphic and heterogeneous sets. with('commentable') works, and MorphTo::morphWith() lets you eager load different nested relations per type.

Blade components and API resources are the usual hiding places. A resource class that calls $this->author->name will N+1 for every item in the collection even though the controller looked clean. whenLoaded() is the fix:

public function toArray($request): array
{
    return [
        'id'     => $this->id,
        'title'  => $this->title,
        'author' => new AuthorResource($this->whenLoaded('author')),
    ];
}

Step 4: Stop loading rows you do not need

Select fewer columns. SELECT * on a table with a longtext body column is expensive before any of it reaches PHP:

Article::select(['id', 'title', 'published_at'])->paginate(20);

Paginate properly. paginate() runs a COUNT(*) over the whole result set on every page load. If you do not need a total page count — infinite scroll, an admin log, an export — use simplePaginate(). For large offsets, cursorPaginate() is dramatically faster because it uses a WHERE clause on the sort key instead of OFFSET 50000.

Chunk and stream batch work. In a console command or job:

Order::where('status', 'pending')
    ->chunkById(1000, function ($orders) {
        foreach ($orders as $order) {
            $order->markStale();
        }
    });

chunkById() rather than chunk() whenever the loop modifies the rows it is iterating — plain chunk() with an offset skips records when the result set shifts underneath it. lazy() and lazyById() give you the same batching with a generator interface, and cursor() streams one row at a time when the query result is huge but each row is cheap.

Push work into SQL. Filtering, summing, and grouping a 100,000-row collection in PHP after ->get() is the single most common "the server ran out of memory" cause we see. ->sum('amount') on a builder is one query; on a collection it is 100,000 hydrated models.

Use toBase() for read-only lists. Hydrating Eloquent models costs real time. When you only need values for a dropdown or a report, DB::table(...) or Model::query()->toBase()->get() returns plain stdClass rows and skips the model overhead entirely.

Step 5: Index what you filter on

Once the query count is sane, the remaining time is index work. Rules of thumb that cover most cases:

  • Index every foreign key. Laravel's foreignId()->constrained() does it for you; hand-written unsignedBigInteger columns often do not have one.
  • Index columns used in WHERE, ORDER BY and JOIN conditions, not columns you only ever display.
  • Composite index order matters. An index on (customer_id, status, created_at) serves WHERE customer_id = ? AND status = ? and WHERE customer_id = ?, but not WHERE status = ? alone. Put the most selective, always-present column first.
  • Watch for unsargable predicates: WHERE DATE(created_at) = ? and WHERE LOWER(email) = ? cannot use a plain index. Compare ranges (whereBetween on the raw column) or add a generated/functional index.
  • Add indexes concurrently on large PostgreSQL tables and outside peak hours on MySQL; on a multi-million-row table a naive ALTER TABLE is an outage.
Schema::table('orders', function (Blueprint $table) {
    $table->index(['customer_id', 'status', 'created_at']);
});

Then re-run EXPLAIN and confirm the plan actually changed. An index that the planner ignores is pure write overhead.

Step 6: Cache the expensive things that rarely change

Caching is the last step, not the first — caching a query that should have been indexed just hides the problem until the cache is cold.

use Illuminate\Support\Facades\Cache;

$stats = Cache::remember("dashboard.stats.{$tenant->id}", now()->addMinutes(10),
    fn () => $this->buildStats($tenant)
);

Practical notes:

  • Use a real cache driver in production (Redis or Memcached). The database driver moves load onto the thing you are trying to protect.
  • Prefer tags or versioned keys over cache clears, so one invalidation does not evict everything.
  • Wrap dogpile-prone keys in Cache::flexible() (stale-while-revalidate) or Cache::lock() so a cold key does not send fifty concurrent requests at the same slow query.
  • Cache computed results, not raw models. Serialising Eloquent models into the cache re-hydrates relationships you did not ask for.

Also cache the framework in every deploy: php artisan config:cache route:cache view:cache event:cache — the optimize command runs the lot.

Step 7: Lock the fix in with a test

Performance regressions come back the moment someone adds a feature to the page. Assert on query count so the next pull request fails instead of the site:

use Illuminate\Support\Facades\DB;

it('renders the order index in a constant number of queries', function () {
    Order::factory()->count(50)->hasLines(3)->create();

    DB::enableQueryLog();

    $this->actingAs(admin())->get('/orders')->assertOk();

    expect(count(DB::getQueryLog()))->toBeLessThan(10);
});

Seed more rows than the page shows. A test with three orders passes happily with an N+1 in it; fifty orders makes the bug obvious.

A quick checklist

  1. Model::shouldBeStrict() on in local and CI.
  2. Pulse or Telescope on to find the worst endpoint, not the one you assume.
  3. Eager load; use withCount/withSum where you only need numbers.
  4. Select fewer columns; cursorPaginate() for deep pages; chunkById() for batch jobs.
  5. EXPLAIN the remaining slow query and index for it.
  6. Cache what is still expensive, with a real driver and sane invalidation.
  7. Add a query-count test so it stays fixed.

Most applications get the majority of their win from steps 1–3 in a single afternoon. If your slow endpoints survive that treatment, the problem is usually architectural — a report that should be a materialised summary table, or synchronous work that belongs on a queue.

We do this as a fixed-scope engagement: profile, fix, and hand back a report with before-and-after numbers. See Laravel performance optimization, or get in touch with your slowest endpoint and we will tell you what we would look at first.