Skip to main content
Laravel, shipping fast.

Chapter 6

Performance & Optimization

Julian Beaujardin

Slow APIs lose users. A 500ms response feels instant. A 3-second response makes a consumer wonder if something's broken, and retry.

"Make it fast later" doesn't work. Optimizing a slow architecture is exponentially harder than building a fast one, because by the time it's slow, three other services depend on the shape it's already in. This chapter covers how to know what's actually slow, the query mistake you will hit first, caching without lying to yourself about freshness, pagination as a decision and not a default, which columns need indexes, why payload size is latency too, and how to confirm the fix actually worked.

You cannot optimize what you have not measured. Everything in this chapter follows from that one sentence.

Measure Before You Optimize

Here's an uncomfortable truth: most performance work is guessing dressed up as engineering. A developer notices an endpoint feels slow, adds an index somewhere, adds a cache somewhere else, ships it, and never confirms which change (if either) did anything. Six months later nobody remembers why that cache exists, and nobody's sure it's helping.

You don't get to guess, because you don't have to. Every authenticated request in the License API already passes through LogApiRequestsMiddleware, which times the request and records the result before you've written a single optimization:

// api-infrastructure/src/Middleware/LogApiRequestsMiddleware.php
final readonly class LogApiRequestsMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $startTime = microtime(true);
        $response = $next($request);

        defer(function () use ($request, $response, $startTime) {
            // ...
            SendToLogsJob::dispatch(
                // ...
                execution_time: microtime(true) - $startTime,
                request_size: $requestSize,
                response_size: $responseSize,
                // ...
            );
        });

        return $response;
    }
}

$startTime = microtime(true): captured before the response is built, so it includes everything downstream, the controller, the facade, the driver's HTTP call to Statamic.

defer(): the timing and logging happen after the response has already been sent to the client. Measuring costs nothing on the request's critical path.

execution_time: the number you'll compare before and after every change in this chapter. If you can't point to this field going down, you didn't optimize anything, you rearranged code.

"Measure first" isn't a step you skip under deadline pressure. The measurement already exists; skipping it means ignoring data you're already paying to collect.

The N+1 You Will Actually Hit

Here's the one that gets everyone: a query that looks perfectly reasonable in isolation runs once per row of a collection instead of once for the whole collection. It doesn't have to be a lazy Eloquent relationship hiding behind a property access, though that's the classic shape. Any query fired from inside a loop body has the same disease.

ReconcileDomainsCommand (app/Console/Commands/ReconcileDomainsCommand.php) sweeps every auto-renewing domain looking for one whose merchant subscription has lapsed. It checks that subscription one domain at a time:

// Bad: a query per iteration
// app/Console/Commands/ReconcileDomainsCommand.php
Domain::query()
    ->whereNotNull('registrar')
    ->where('auto_renew', true)
    ->cursor()
    ->each(function (Domain $domain) {
        if ($this->hasActiveMerchantSubscription($domain)) {
            return;
        }
        // ... flag the leak
    });

private function hasActiveMerchantSubscription(Domain $domain): bool
{
    return Subscription::query()
        ->where('stripe_id', $domain->stripe_subscription_id)
        ->whereNull('ends_at')
        ->exists();
}

cursor() keeps the domains themselves from ever sitting fully in memory, the right call on a table that can run to thousands of rows. But hasActiveMerchantSubscription() still fires a fresh Subscription query for every domain that passes through the closure. A thousand auto-renewing domains means a thousand subscription lookups: one query per row, dressed up in a memory-efficient loop.

// Good: one query per batch, not one per domain
Domain::query()
    ->whereNotNull('registrar')
    ->where('auto_renew', true)
    ->chunk(500, function ($domains) {
        $activeSubscriptionIds = Subscription::query()
            ->whereIn('stripe_id', $domains->pluck('stripe_subscription_id')->filter())
            ->whereNull('ends_at')
            ->pluck('stripe_id')
            ->flip();

        foreach ($domains as $domain) {
            if ($activeSubscriptionIds->has($domain->stripe_subscription_id)) {
                continue;
            }
            // ... flag the leak
        }
    });

chunk(500) trades cursor()'s constant memory for a bounded batch: 500 domains held at once instead of one. In exchange, the subscription check collapses to a single whereIn() per batch instead of one query per domain, two queries per 500 domains instead of 500. Fewer queries almost always costs you something else, here it's memory instead of an unbounded connection count. The fix isn't free. It's just a better trade.

The same principle holds for a single request, not just a bulk sweep. EditableAiController (app/Http/Controllers/EditableAiController.php) resolves an editing token, then needs that token's site, the site's server, and the server's workspace, three more BelongsTo hops. Loaded lazily, that's three more queries after the first. The real code eager loads the whole chain up front instead:

// app/Http/Controllers/EditableAiController.php
$token = Token::query()
    ->with('site.server.workspace')
    ->where('id', $tokenString)
    ->first();

One query instead of four. It's not a bulk sweep, it's a single request, but the principle is identical: know the relationship chain you actually need, and load it in one query instead of letting each hop trigger its own.

The fix is obvious once someone points at it in review. The problem is nobody points at it until a job that used to finish in seconds is quietly taking minutes. The rule: any query, lazy relationship or explicit call, that runs once per row of something you're iterating gets batched or eager-loaded before you commit, not after someone notices.