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-Controlheaders 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
AcceptandAuthorizationwhen caching API responses to prevent cross-user leakage.