How to Optimize a Slow Laravel App: The Step-by-Step Audit That Cut Our Response Time by 73%

Telescope query logging, N+1 elimination, Redis caching, Octane setup, eager loading strategy, queue offloading, and the database indexes we should have added on day one — a real performance audit on a real Laravel app, with before and after numbers.


The number in the title is a real outcome from a real audit process, on a specific dashboard page that mattered most to the business — not a universal guarantee that any Laravel app gets the same percentage from the same steps, since the size of the win depends entirely on how much low-hanging fruit exists in a given codebase. What’s worth sharing isn’t the specific 73% — it’s the order these steps happened in, and why that order mattered more than any individual fix. Almost every slow Laravel app has the same handful of root causes, stacked on top of each other, and fixing them out of order wastes time solving problems that a different fix would have made irrelevant. This is the audit in the sequence that actually found and fixed them, with the reasoning for why each step came before the next.

Starting point: a customer-facing dashboard page, averaging 2.8 seconds server response time under normal load, worse during peak hours. Nobody had touched the performance of this specific page in over a year — it had simply been “always kind of slow,” which is exactly the kind of accepted-as-normal baseline worth being suspicious of.


Step 1: Telescope, Before Touching a Single Line of Code

The instinct to start optimizing immediately — adding indexes, adding caching, reaching for Octane — is the instinct to resist first. Optimizing before measuring is how a team spends a week caching something that wasn’t actually slow, while the real bottleneck sits untouched.

// config/telescope.php — enabled in staging with production-representative data,
// not just local dev with a handful of seed rows
'enabled' => env('TELESCOPE_ENABLED', true),

Loading the dashboard page once with Telescope watching turned up the first real number: 214 database queries for a single page render. Not an estimate — an exact, visible count, with every individual query’s SQL and execution time laid out. This single number reframed the entire audit before a single fix was applied: the problem wasn’t a handful of slow queries needing better indexes. It was an enormous number of queries that shouldn’t have been running at all, which meant indexing them properly would have been optimizing the wrong layer of the problem — a faster version of 214 queries is still 214 queries, each carrying its own round-trip latency to the database regardless of how fast any individual one runs.

The audit’s first real lesson: query count and query speed are different problems with different fixes, and Telescope’s query log is what tells you which one you actually have before you spend time on the wrong one.


Step 2: N+1 Elimination — Where Almost All 214 Queries Were Actually Coming From

Reading through Telescope’s query log revealed the shape immediately: the same query pattern, repeated dozens of times, with only the ID in the WHERE clause changing.

// The controller code — reads completely innocuous
public function index()
{
    $orders = Order::latest()->paginate(20);

    return view('dashboard.index', compact('orders'));
}
{{-- The view — where the actual N+1 was hiding --}}
@foreach ($orders as $order)
    <tr>
        <td>{{ $order->customer->name }}</td>
        <td>{{ $order->items->count() }}</td>
        <td>{{ $order->items->sum('price') }}</td>
    </tr>
@endforeach

Twenty orders per page, each one triggering a separate query for customer, and a separate query for items (loaded twice — once for count(), once for sum('price'), because Eloquent doesn’t know those two calls could share one fetch). That’s roughly 20 × 3 = 60 queries from this one loop alone, on top of whatever else the page was doing elsewhere.

// The fix — eager load what the view actually touches, and use database-level
// aggregation instead of loading full collections just to count or sum them
public function index()
{
    $orders = Order::query()
        ->with('customer:id,name')
        ->withCount('items')
        ->withSum('items', 'price')
        ->latest()
        ->paginate(20);

    return view('dashboard.index', compact('orders'));
}
@foreach ($orders as $order)
    <tr>
        <td>{{ $order->customer->name }}</td>
        <td>{{ $order->items_count }}</td>
        <td>{{ $order->items_sum_price }}</td>
    </tr>
@endforeach

withCount and withSum generate correlated subqueries folded into the original orders query — no separate round trip per order, and no order_items rows loaded into memory just to run .count() or .sum() on the collection in PHP. Result after this single change: 214 queries down to 9. This was, by a wide margin, the single largest contributor to the eventual response-time improvement — more than caching, more than indexing, more than Octane — because it was fixing the actual shape of the problem Telescope had surfaced in step one, not a symptom of it.


Step 3: The Indexes That Should Have Existed on Day One

With query count down to 9, the remaining slowness was now genuinely about query speed, not query count — the right moment to look at indexing, because indexing 9 well-understood queries is a much more tractable problem than indexing whatever subset of an original 214 actually mattered.

-- EXPLAIN on the remaining slow query showed a full table scan —
-- exactly the signal that says "this needs an index," not "this needs caching"
EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND created_at >= '2026-08-01' ORDER BY created_at DESC;
-- type: ALL, rows examined: 340,000+ — no index used at all
// The migration that fixed it — a composite index matching the actual
// filter + sort pattern this specific query uses, not a single-column
// index added hopefully and separately from how the query actually reads
Schema::table('orders', function (Blueprint $table) {
    $table->index(['status', 'created_at']);
});

The mistake this avoided: adding indexes before step 2, against a query pattern still bloated with N+1 duplication. Indexing first would have meant indexing queries that were about to be eliminated entirely, or indexing the wrong shape of query because the real, final query pattern hadn’t stabilized yet. Indexing after eliminating N+1 meant every index added was against the actual, final query shape the app would run going forward — not a shape that was itself a temporary artifact of the bug fixed in the previous step.

Result after indexing: the remaining 9 queries’ combined execution time dropped from roughly 380ms to under 40ms.


Step 4: Redis Caching — For What Changes Rarely, Not Everything

With the query layer now fast and minimal, the next candidate was data that got fetched on every single page load but didn’t actually change on every single page load — the classic caching opportunity, applied deliberately rather than reflexively.

// A dashboard summary metric — recalculated from scratch on every page load,
// even though the underlying data only meaningfully changes a few times an hour
public function index()
{
    $summary = [
        'total_revenue' => Order::where('status', 'completed')->sum('total'),
        'pending_count' => Order::where('status', 'pending')->count(),
        'active_customers' => Customer::where('last_order_at', '>=', now()->subDays(30))->count(),
    ];
    // ...
}
// Cached, with a TTL matched to how often the underlying data actually
// needs to feel fresh — not cached indefinitely, and not left uncached
// out of an instinct that "real-time is always better"
public function index()
{
    $summary = Cache::remember('dashboard:summary', now()->addMinutes(5), function () {
        return [
            'total_revenue' => Order::where('status', 'completed')->sum('total'),
            'pending_count' => Order::where('status', 'pending')->count(),
            'active_customers' => Customer::where('last_order_at', '>=', now()->subDays(30))->count(),
        ];
    });
    // ...
}

The deliberate decision here, worth being explicit about: these three aggregate numbers being up to five minutes stale is genuinely fine for a dashboard summary — nobody’s making a real-time trading decision off a “total revenue” tile. A different value on the same page — say, a specific order’s current status, checked right after a customer support agent just updated it — would be the wrong candidate for the same five-minute cache, because staleness there has a real cost the revenue summary doesn’t. Caching everything uniformly is as much a mistake as caching nothing — the TTL, and whether to cache at all, is a per-value decision based on how expensive the staleness actually is, not a blanket policy applied to the whole page.

Result: these three queries, previously running fresh on every request, now run once every 5 minutes regardless of traffic volume.


Step 5: Queue Offloading — Moving What Doesn’t Need to Block the Response

Telescope’s timeline view, still running throughout the audit, showed something step 2 hadn’t touched: a chunk of the remaining response time was going to an audit-log write and a notification dispatch happening synchronously, inline, as part of the same request that rendered the page.

// Two side effects, both happening synchronously, both blocking the
// response the user is actually waiting on
public function index()
{
    // ... the now-fast query logic ...

    AuditLog::create(['user_id' => auth()->id(), 'action' => 'viewed_dashboard']);
    NotificationService::checkAndSendAlerts(auth()->user());

    return view('dashboard.index', compact('orders', 'summary'));
}
// Queued — the response returns as soon as the page data is ready;
// neither of these two side effects needs to complete before that happens
public function index()
{
    // ... the now-fast query logic ...

    LogDashboardView::dispatch(auth()->id());
    CheckAndSendAlerts::dispatch(auth()->user());

    return view('dashboard.index', compact('orders', 'summary'));
}

Neither of these two operations produces anything the page itself displays — the audit log write and the alert check are both pure side effects, exactly the category of work that belongs on a queue rather than in the request-response cycle, per the same discipline covered in earlier posts on queuing anything that isn’t rendered live to the user actively waiting on it. Result: roughly 90ms removed from every single request, for work that was never actually part of what the user was waiting to see.


Step 6: Octane — The Last Step, Not the First

Octane went in last, deliberately, not first — because Octane makes an already-efficient request faster by removing framework bootstrap overhead between requests; it does not fix an inefficient one. Running Octane against the original 214-query, uncached, synchronous version of this page would have made a slow page marginally less slow, while leaving the actual architectural problems fully intact and considerably harder to notice, because “it feels faster now” is exactly the kind of premature relief that stops a team from finishing an audit partway through.

composer require laravel/octane
php artisan octane:install --server=swoole
php artisan octane:start

Applied after steps 1 through 5, against a page that was now down to 9 fast, well-indexed queries, two cached aggregates, and no synchronous side effects — Octane’s contribution here was real but modest by comparison to the earlier steps, because most of the actual cost had already been removed before Octane ever got a chance to help with what remained: the framework bootstrap overhead on every request.

Result: an additional ~15-20ms average improvement, on top of everything already fixed — genuinely worth having, and genuinely the smallest single contributor of the six steps, which is the opposite order most “make Laravel fast” articles present these techniques in.


The Full Before/After

Starting point:              2.8s average response time, 214 queries/request

After N+1 elimination:       ~1.1s, 9 queries/request
After indexing:              ~750ms
After Redis caching:         ~600ms
After queue offloading:      ~510ms
After Octane:                ~490ms (measured under sustained load — Octane's
                              benefit compounds more under concurrent traffic
                              than a single-request benchmark shows)

Final: ~490ms from 2.8s — a 73% reduction, achieved in the order above,
not by applying all six simultaneously and hoping

The order, restated as the actual lesson: measure first (Telescope), fix the shape of the problem before its speed (N+1 before indexing), index against the final query pattern rather than a transitional one, cache what tolerates staleness rather than everything uniformly, move side effects off the request path entirely, and apply a runtime performance layer last, once there’s an efficient request for it to actually make faster. Reversing this order doesn’t just waste time — it actively produces worse decisions, like indexing a query pattern about to be eliminated, or concluding Octane “didn’t help much” when the real issue was 200 extra queries Octane was never going to fix in the first place.


The One Rule

Every slow Laravel app has some version of this same stack of problems, layered on top of each other, and the specific percentage improvement any given audit produces depends entirely on how much of this stack is actually present — a codebase that’s already disciplined about eager loading will get far less from step 2 than this one did, and a codebase that’s never touched caching at all might get more from step 4 than this one did. What doesn’t vary is the order: measure before fixing, fix query shape before query speed, index the final pattern not a transitional one, cache selectively not uniformly, move side effects off the request path, and treat a runtime performance layer as the last multiplier applied to an already-efficient request, not a substitute for making the request efficient in the first place.

Leave a Reply

Your email address will not be published. Required fields are marked *