Laravel Database Performance at Scale in 2026: Killing N+1 Queries, Indexing, and Read Replicas

June 01, 2026

Your app was fast with 10,000 rows. Now it has 10 million, the same dashboard takes four seconds to render, and the database CPU graph looks like a heart attack. Nothing in your code “broke” — it just stopped scaling. Most Laravel performance problems at scale come down to a small set of recurring mistakes: queries fired in loops, missing or wrong indexes, every read hammering the primary, and a connection layer that quietly falls over under load.

This guide walks through how to find the real bottleneck and fix it, using actual Laravel APIs and without guessing.

Find the Real Bottleneck Before Touching Anything

The cardinal rule: measure first. Optimizing a query that runs twice a day while ignoring the one that runs 400 times per request is wasted effort. You need data on which queries actually hurt.

Laravel’s documented mechanism for this is DB::listen, registered in a service provider’s boot():

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

DB::listen(function (QueryExecuted $query) {
    Log::debug('query', [
        'sql'  => $query->toRawSql(), // bindings interpolated
        'time' => $query->time,       // milliseconds
    ]);
});

For alerting on cumulative slowness within a request, use whenQueryingForLongerThan (threshold in milliseconds):

use Illuminate\Database\Connection;

DB::whenQueryingForLongerThan(500, function (Connection $connection, QueryExecuted $event) {
    // Notify the team — total query time crossed 500ms this request.
});

Once you have a suspect query, run EXPLAIN against it at the database level. Laravel has no first-class explain() builder method, so capture the SQL and run it raw:

DB::select('EXPLAIN SELECT * FROM orders WHERE customer_id = ?', [$id]);

You’re looking for full table scans, large row estimates, and filesorts where you expected an index seek. The CLI helpers php artisan db:show, php artisan db:table orders, and Schema::getIndexes('orders') tell you what indexes already exist before you add more.

The measurement order that matters: identify the slow endpoint, count its queries, find the worst individual query, EXPLAIN it, then fix it. Re-measure after every change. Skip this and you’ll “optimize” things that were never the problem.

Kill N+1 Queries

The most common scaling killer is the N+1 query — lazy-loading a relationship inside a loop:

$books = Book::all();

foreach ($books as $book) {
    echo $book->author->name; // one query per book
}

For 25 books this runs 26 queries: one for the books, then one per author. Eager loading collapses it to two:

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

Detect Regressions Automatically

The fix is easy once you know it’s there — the hard part is catching new ones. Turn on strict mode so any lazy load throws instead of silently working. Gate it to non-production so a missed case can’t take down prod:

use Illuminate\Database\Eloquent\Model;

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

Model::shouldBeStrict(! $this->app->isProduction()) is the convenience method that also prevents accessing missing attributes and silently discarding non-fillable ones. With strict mode on in CI, an N+1 introduced in a pull request fails your test suite with a LazyLoadingViolationException instead of reaching customers.

The Eager-Loading Toolkit

  • with() — eager-load at query time. Supports multiple relations with(['author', 'publisher']), nested via dot notation with('author.contacts'), and column selection (include the foreign key): with('author:id,name').
  • load() / loadMissing() — load relations onto models you already retrieved. loadMissing() is idempotent: it only loads what isn’t already loaded, so it’s safe to call defensively.
  • Constrained eager loads — pass a closure to filter children: with(['posts' => fn (Builder $q) => $q->where('published', true)]).
  • withCount() / withSum() / withExists() — aggregate via subquery without hydrating rows. Post::withCount('comments')->get() gives each post a comments_count attribute and no extra query.
  • withWhereHas() — filter parents that have matching children and eager-load only those children in one call.

Reach for column-scoped eager loads (with('author:id,name')) on hot paths — pulling back full related rows you never use is its own form of waste.

Index Deliberately, Select Narrowly

An index lets the database seek instead of scanning. Add them where you filter, join, sort, or group:

Schema::table('orders', function (Blueprint $table) {
    $table->index(['account_id', 'created_at']);
});

That composite index follows the leftmost-prefix rule: it serves queries on account_id, or account_id + created_at, but not created_at alone. Column order is a design decision, not a detail.

Indexes are not free. Every INSERT/UPDATE/DELETE must maintain each one, and they consume storage, so over-indexing a write-heavy table makes it slower. Low-selectivity columns (a boolean flag) rarely justify a standalone index. Note that foreign keys created with $table->foreignId('user_id')->constrained() already add a supporting index — don’t duplicate it.

Stop Selecting Everything

SELECT * transfers and hydrates columns you don’t need. Name the columns:

$users = User::select('id', 'name', 'email')->get();

Beyond cutting row width, a narrow select can be satisfied entirely by a covering index — one that contains every selected column — so the database never touches the table itself.

Process Large Sets Without Exhausting Memory

Never ->get() a million rows into memory. Laravel gives you several streaming options, each for a different job:

  • chunk(100, ...) — pulls rows in batches into Collections for batch processing.
  • chunkById(100, ...) — use this when mutating rows while iterating. It paginates by primary key and injects its own where id > ?, so updating rows doesn’t shift the result window and skip records.
  • lazy() — streams a LazyCollection; unlike cursor() it still supports eager loading.
  • cursor() — a single query, one model in memory at a time via generators. Lowest per-model footprint, but it cannot eager-load relationships, and PDO buffers raw results internally, so on truly huge sets prefer lazy().
Order::where('status', 'pending')
    ->chunkById(500, function ($orders) {
        foreach ($orders as $order) {
            $order->update(['status' => 'archived']);
        }
    });

Use Cursor Pagination at Scale

Offset pagination (paginate()) runs ... LIMIT 15 OFFSET 15, and the database must scan through every skipped row to reach the offset — slower and slower as users page deeper. It also runs a separate COUNT(*). Cursor pagination uses a keyset (WHERE id > 15) instead:

$orders = Order::orderBy('id')->cursorPaginate(15);

Per the docs, cursor pagination offers better performance on large data sets when the order-by columns are indexed, and it’s stable under concurrent writes (offset pagination can skip or duplicate rows when data shifts). The trade-offs: next/previous links only (no page numbers), ordering must be on a unique column, and there’s no total(). For deep-scrolling lists and APIs that don’t need a page count, it’s the right default.

Split Reads to Replicas

Once a single primary can’t absorb the read load, route SELECTs to read replicas. Laravel supports this natively in config/database.php with read, write, and sticky keys:

'mysql' => [
    'driver' => 'mysql',
    'read' => [
        'host' => ['192.168.1.1', '192.168.1.2'],
    ],
    'write' => [
        'host' => ['192.168.1.3'],
    ],
    'sticky' => true,
    // host-independent options merged from the main config:
    'port' => env('DB_PORT', '3306'),
    'database' => env('DB_DATABASE', 'laravel'),
    'username' => env('DB_USERNAME', 'root'),
    'password' => env('DB_PASSWORD', ''),
],

You only override what differs (usually host); everything else merges from the main array. Routing is automatic and by query type: select/scalar go to a read host, while insert/update/delete/statement go to write. When multiple read hosts exist, Laravel picks one randomly per request — that’s fan-out, not weighted load balancing.

Read-After-Write: the sticky Caveat

Replicas lag. Without protection, you can write a row to the primary and then read a replica that hasn’t caught up, getting stale or missing data. The sticky option fixes this: once any write happens in a request, subsequent reads in that same request use the write connection.

The boundaries matter, and getting them wrong causes subtle bugs:

  • sticky only covers a single request cycle. A new request or queue job starts fresh and may read a lagging replica. With a long-lived queue:work worker reusing the connection, stickiness can also persist across jobs in surprising ways.
  • Transactions always use the write connection — including SELECTs inside DB::transaction(). That’s correct for read-after-write consistency, but it means you cannot offload reads to a replica from inside a transaction.

Connection Pooling: Set Correct Expectations

Here’s the uncomfortable truth: PHP has no native cross-request connection pooling. Under classic PHP-FPM, each request boots the framework and opens its own database connection. Crank up FPM workers and you crank up backend connections one-for-one — straight toward max_connections exhaustion. PDO persistent connections are per-worker and brittle, not a real pool.

There are two real answers in 2026, and they’re complementary.

External pooler (PgBouncer for Postgres). Put PgBouncer between the app and the database and point Laravel at it. transaction pool mode gives the highest connection reuse and is the common choice for web apps. The historical gotcha is prepared statements: transaction mode didn’t support server-side prepares, so the Laravel workaround was PDO::ATTR_EMULATE_PREPARES => true in the connection options — which can mis-cast Postgres booleans, hence community connection-class fixes. As of PgBouncer 1.21+, it can track prepared statements in transaction mode with max_prepared_statements > 0, but PHP/PDO compatibility needs PHP 8.4+ and libpq 17. On older stacks, stick with emulated prepares. (MySQL’s analog is ProxySQL.)

Laravel Octane. Octane boots the app once and keeps it in memory, so workers are long-lived and connections persist across requests within a worker — warm connections, less connect churn. The pitfalls are all about persistent state:

  • Don’t hold resolved singletons that capture a stale container, request, or config. Inject resolver closures (fn () => Container::getInstance()) or use the app/config/request global helpers.
  • Avoid static accumulation. Appending to a static array leaks memory across requests; with APP_DEBUG=true the query log itself accumulates.
  • Lean on worker recycling. Octane restarts a worker after 500 requests by default; lower it with --max-requests=250 if you suspect leakage.

Octane and PgBouncer aren’t mutually exclusive — a fleet of Octane workers across many instances can still overwhelm Postgres, so PgBouncer remains the ceiling on total backend connections. Whatever you run, watch open connections with php artisan db:monitor --databases=mysql --max=100, which fires a DatabaseBusy event past the threshold.

Monitoring Checklist

Performance work is permanent, not a one-time cleanup. Track these continuously:

  • Queries per request on hot endpoints — a sudden jump usually means a new N+1.
  • p95 query time, not just averages — the tail is what users feel.
  • The database’s slow query log — your source of truth for what’s actually slow in production.
  • Replica lag — rising lag widens the read-after-write window sticky can’t cover across requests.
  • Open connection count via db:monitor / DatabaseBusy — the early warning for pool exhaustion.
  • N+1 regressions in CI — strict mode (preventLazyLoading / shouldBeStrict) turning violations into failed builds.

Rollout Checklist

Apply changes in this order, safely, one at a time:

  1. Instrument first. Add DB::listen and whenQueryingForLongerThan and identify the worst endpoints with real data.
  2. Enable strict mode in non-production and CI to surface existing and future N+1s.
  3. Fix N+1s with with / load / withCount, scoping columns on hot paths.
  4. EXPLAIN the remaining slow queries and add targeted composite indexes; verify each one helps and re-measure write impact.
  5. Replace SELECT * with column selects and switch deep lists to cursorPaginate.
  6. Convert large batch jobs to chunkById / lazy / cursor.
  7. Add read/write splitting with sticky = true only after reads are genuinely the bottleneck; verify read-after-write behavior in transactions and queues.
  8. Address the connection layer — PgBouncer (transaction mode) and/or Octane — and monitor open connections.

Conclusion

Laravel database performance at scale isn’t about a secret setting — it’s discipline. Measure before you change anything, kill N+1s with eager loading and lock the gains in with strict mode in CI, index for the queries you actually run, and only reach for replicas and poolers once the data says reads or connections are the real ceiling. Do it in that order, re-measure after every step, and your dashboard will stop looking like a heart attack — even at ten million rows.


Published by Artiphp who lives and works in San Francisco building useful things.