Skip to main content
Laravel, shipping fast.

Chapter 5

Performance & Optimization

Julian Beaujardin

Slow APIs lose users. There's no way around it. A 500ms API response feels instant. A 3-second response makes users wonder if something's broken.

"Make it fast later" doesn't work. Optimizing a slow architecture is exponentially harder than building a fast one from the start. Performance debt is like regular debt, it compounds.

In this chapter, we'll talk about the practical optimizations that matter: database query optimization, caching, compression, and monitoring.

Compression: Make Responses Smaller

Simple idea: compress responses with gzip. A 100KB JSON response becomes 10KB. Your users download 90% less data. Everyone wins.

class GzipResponseMiddleware
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        // Client must support gzip encoding
        if (!str_contains($request->header('Accept-Encoding'), 'gzip')) {
            return $response;
        }

        // Compress response
        $response->setContent(gzencode($response->getContent(), 9));
        $response->header('Content-Encoding', 'gzip');

        return $response;
    }
}

// Register in bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->api(append: [
        GzipResponseMiddleware::class,
    ]);
})

Gzip is transparent to the client. Modern browsers and API clients automatically decompress. You get ~90% size reduction for free.

Query Optimization: Avoid N+1

The classic database mistake: loading a list of domains, then looping through them to get the user for each one. That's N database queries just to get the user names!

Fix it with eager loading:

// ❌ Bad: N+1 queries (disaster!)
$domains = Domain::all(); // 1 query
foreach ($domains as $domain) {
    echo $domain->user->name; // +1 query per domain = N queries
}
// Total: 1 + N queries where N = number of domains

// ✅ Good: Eager loading (one query total)
$domains = Domain::with('user')->get();
foreach ($domains as $domain) {
    echo $domain->user->name; // Already loaded
}
// Total: 2 queries (domains + users)

// ✅ Even Better: Load only needed columns
$domains = Domain::with([
    'user' => fn ($q) => $q->select('id', 'name'),
    'records' => fn ($q) => $q->latest()->limit(5),
])->get();

// Load only the columns and relationships you actually use

This is one of the highest-impact optimizations you can make. Loads of slow APIs are slow because of this mistake.

Caching: Store Expensive Computations

Database queries take time. API calls to external services take a lot of time. Cache frequently accessed data:

class DomainService
{
    // Cache all domains for 1 hour
    public function licenses(): Collection
    {
        $cached = Cache::get('domains:all');
        if ($cached) {
            return $cached;
        }

        $domains = Domain::all();
        Cache::put('domains:all', $domains, 3600);

        return $domains;
    }

    // Or use Cache::remember for convenience
    public function licensesByUser(Bearer $bearer): Collection
    {
        return Cache::remember(
            "domains:bearer:{$bearer->id}",
            3600,
            fn () => $bearer->domains()->get()
        );
    }

    // Invalidate cache when domain changes
    public function updateDomain(Domain $domain, array $data): void
    {
        $domain->update($data);

        // Clear affected caches
        Cache::forget('domains:all');
        Cache::forget("domains:bearer:{$domain->bearer_id}");
    }
}

Caching strategy:

  • Identify slow operations (queries, API calls)
  • Cache the result
  • Invalidate when the underlying data changes
  • Use tags to group related cache entries