Laravel Performance: HTTP Response Caching, Cache Tags, and Stale-While-Revalidate
#laravel #caching #performance #redis

Laravel Performance: HTTP Response Caching, Cache Tags, and Stale-While-Revalidate

3 min read Mohamed Said Mohamed Said

Beyond cache()->remember(): Response-Level Caching in Laravel

Most Laravel apps cache individual values. Fewer cache entire HTTP responses. Fewer still wire up tagged invalidation and stale-while-revalidate (SWR) semantics. Each layer compounds the benefit — here is how to stack them correctly.


HTTP Response Caching with Middleware

The fastest database query is the one you never make. A dedicated response-cache middleware stores the full serialised response and replays it on subsequent requests.

// app/Http/Middleware/CacheResponse.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class CacheResponse
{
    public function handle(Request $request, Closure $next, int $ttl = 60): mixed
    {
        if ($request->method() !== 'GET') {
            return $next($request);
        }

        $key = 'response:' . sha1($request->fullUrl());

        if ($cached = Cache::get($key)) {
            return response($cached['body'], 200, $cached['headers'])
                ->header('X-Cache', 'HIT');
        }

        $response = $next($request);

        if ($response->isSuccessful()) {
            Cache::put($key, [
                'body'    => $response->getContent(),
                'headers' => $response->headers->all(),
            ], $ttl);
        }

        return $response->header('X-Cache', 'MISS');
    }
}

Register it per-route or per-group:

Route::get('/products', ProductController::class)
    ->middleware('cache.response:300');

Tagged Cache Invalidation

A flat key like response:sha1(url) is hard to invalidate when a product changes. Cache tags solve this — but only with Redis or Memcached drivers.

// Storing with tags
Cache::tags(['products', 'product:42'])->put($key, $payload, $ttl);

// Invalidating on model update
class Product extends Model
{
    protected static function booted(): void
    {
        static::saved(function (Product $product) {
            Cache::tags([
                'products',
                "product:{$product->id}",
            ])->flush();
        });
    }
}

Update the middleware to accept a variadic tag list:

public function handle(Request $request, Closure $next, string ...$tags): mixed
{
    $key  = 'response:' . sha1($request->fullUrl());
    $store = empty($tags) ? Cache::store() : Cache::tags($tags);

    if ($cached = $store->get($key)) {
        return response($cached['body'], 200, $cached['headers'])
            ->header('X-Cache', 'HIT');
    }

    $response = $next($request);

    if ($response->isSuccessful()) {
        $store->put($key, [
            'body'    => $response->getContent(),
            'headers' => $response->headers->all(),
        ], 300);
    }

    return $response->header('X-Cache', 'MISS');
}

Route registration:

Route::get('/products/{product}', ShowProduct::class)
    ->middleware('cache.response:products,product:42');

Stale-While-Revalidate Semantics

SWR serves a stale cached response immediately while refreshing the cache asynchronously. This eliminates the "thundering herd" on expiry.

use Illuminate\Support\Facades\Bus;

public function handle(Request $request, Closure $next, int $ttl = 60, int $grace = 30): mixed
{
    if ($request->method() !== 'GET') {
        return $next($request);
    }

    $key      = 'response:' . sha1($request->fullUrl());
    $graceKey = $key . ':grace';
    $cached   = Cache::get($key);

    if ($cached) {
        // If within grace period, serve stale and revalidate in background
        if (!Cache::has($graceKey)) {
            Cache::put($graceKey, true, $grace);
            dispatch(new RevalidateCachedResponse($request->fullUrl(), $key, $ttl))
                ->afterResponse();
        }

        return response($cached['body'], 200, $cached['headers'])
            ->header('X-Cache', 'STALE');
    }

    $response = $next($request);

    if ($response->isSuccessful()) {
        Cache::put($key, [
            'body'    => $response->getContent(),
            'headers' => $response->headers->all(),
        ], $ttl + $grace); // store for full window
    }

    return $response->header('X-Cache', 'MISS');
}

RevalidateCachedResponse is a queued job that makes an internal HTTP request (or re-runs the controller action) and writes a fresh entry.

Sending Correct HTTP Cache Headers

Don't forget to emit Cache-Control so CDNs and browsers participate:

return $response
    ->header('Cache-Control', "public, max-age={$ttl}, stale-while-revalidate={$grace}")
    ->header('Vary', 'Accept, Accept-Encoding');

Key Takeaways

  • Response-level caching eliminates DB and render overhead entirely for cacheable GET routes.
  • Cache tags (Redis/Memcached only) let you invalidate by entity rather than guessing keys.
  • Stale-while-revalidate prevents thundering-herd expiry spikes without sacrificing freshness.
  • Always emit Cache-Control headers so CDN layers (Cloudflare, Fastly) can participate.
  • Keep the grace-period key TTL shorter than the revalidation job's expected runtime to avoid double-dispatch.
  • Vary on Accept and Authorization when caching API responses to prevent cross-user leakage.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Which Laravel cache drivers support cache tags?
Only the Redis and Memcached drivers support cache tags. The file and database drivers do not. If you call Cache::tags() on an unsupported driver, Laravel throws a BadMethodCallException at runtime.
Q02 How do I prevent authenticated users from receiving each other's cached responses?
Include a user-specific component in the cache key (e.g. the authenticated user's ID or role) and add 'Vary: Authorization' to the response headers. Never cache responses that contain user-specific data under a shared key.
Q03 Is stale-while-revalidate safe for pages with CSRF tokens or session data?
No. Response caching is only appropriate for stateless, public GET endpoints. Pages that embed CSRF tokens, session flash data, or personalised content must bypass the cache entirely.

Continue reading

More Articles

View all