Response Cache#
Serve cached responses for safe requests (typically GET/HEAD) without running handlers. This middleware provides a fast-path lookup and a coherent invalidation story that plays well with ETag/Last-Modified and Compression.
Configuration#
use Infocyph\Webrick\Middleware\ResponseCacheMiddleware;
use Psr\SimpleCache\CacheInterface;
$preGlobal[] = new ResponseCacheMiddleware(
store: Cache::local('http'), // PSR-16 simple cache
ttlSeconds: 10, // Base TTL (micro-cache strategy)
includeQuery: true, // Include query params in cache key
maxBodyBytes: 1_048_576, // Max body size to cache (1MB)
defaultVary: ['Accept', 'Accept-Language', 'Accept-Encoding'],
skipWhenPersonalized: true, // Don't cache Set-Cookie responses
respectResponseCacheControl: true, // Honor no-store/private directives
avoidSetCookie: true // Skip caching if Set-Cookie present
);
Constructor Parameters#
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
required |
PSR-16 cache implementation |
|
|
|
Default TTL for cached responses |
|
|
|
Include query string in cache key |
|
|
|
Maximum response body size to cache (bytes) |
|
|
|
Default Vary dimensions to include in key |
|
|
|
Skip caching if response varies by user |
|
|
|
Honor Cache-Control directives from response |
|
|
|
Never cache responses with Set-Cookie header |
How It Keys#
Cache key structure:
{method}|{host}|{path}|{query}|{media}|{charset}|{locale}|{encoding}|{vary_surface}
Example:
GET|example.com|/products/42|sort=price|application/json|utf-8|en|br|Accept:application/json|Accept-Language:en
Hashed: Uses fast XXH3 hash to keep key size small:
http:xxh3:a1b2c3d4e5f6
What Affects the Key#
HTTP Method: GET, HEAD (others not cached)
Host + Path:
/users/42onapi.example.comQuery String (if
includeQuery: true):?page=2&sort=nameNegotiated Content:
Media type:
application/jsonCharset:
utf-8Locale:
enEncoding:
br(if cached post-compression)
Vary Surface: Actual header values for
Varydimensions
Not Included (by design):
Request headers like
User-Agent,RefererCookies (unless explicitly added to Vary)
Request body
Time of request
What it does#
Checks a cache store for a previously rendered response for the current request (usually by route name + params + query + negotiated media).
If found → returns the cached response immediately (with headers/body/status), skipping the handler.
If not found → lets the handler run, then stores the response if it’s cacheable.
Coordinates with Vary (e.g.,
Accept,Accept-Encoding) to avoid mixed variants.Obeys Cache-Control directives on responses (
no-store,max-age,private, etc.) and avoids caching when unsafe.
Think of it as application-level read-through caching. It complements (not replaces) CDN/reverse-proxy caches.
Wiring#
Place in pre-global, after negotiation and before cache validators (or alongside, depending on your strategy):
$preGlobal = [
// hardening, telemetry, limits, throttle, cookies, normalize, sanitizer...
\Infocyph\Webrick\Middleware\NegotiationMiddleware::class,
\Infocyph\Webrick\Middleware\ResponseCacheMiddleware::class,
\Infocyph\Webrick\Middleware\CacheValidatorsMiddleware::class,
// ... router → post-globals (compression, CORS, vary) ...
];
Why before validators? If you hit a warm cache entry, you can bypass validator checks entirely. Alternatively, you may choose validators first and only cache on 200-paths; both are viable—pick one consistently.
Keying strategy (conceptual)#
Default cache key often includes:
Route name (or path template)
Route params (e.g.,
id=42)Query params (normalized order)
Negotiation results (media type, locale)
Optional auth/tenant dimension (for private caches)
Don’t include ephemeral headers like
User-Agent. Keep keys minimal but correct.
What gets cached (and what doesn’t)#
Cacheable by default:
200/203/204 responses to GET/HEAD with public or implicit cacheability
Responses with explicit
Cache-Control: public, max-age=...ors-maxage=...
Not cached:
Set-Cookieresponses (unless you explicitly allow private caching)Cache-Control: no-storeorprivate(by default)Non-idempotent methods (POST/PUT/PATCH/DELETE)
Streaming/SSE responses (size indeterminate)
Bodies below a threshold can still be cached; the decision is policy-based rather than size-based
TTLs & invalidation#
TTL: derive from
Cache-Control: max-ageor a default fallback (e.g., 60s).Stale-while-revalidate (optional): serve slightly stale and refresh in the background (if your implementation supports it).
Manual invalidation: expose helpers to purge by key, by route + params, or by tags.
Event-driven: invalidate when the underlying resource changes (e.g., after updating a user, purge
users.show:id).
Start with short TTLs (30–120s) on hot reads; widen once confident.
Private vs public cache#
Public (shared) cache: safe for content identical across users (e.g.,
/status, blog pages).Private (per-user) cache: key includes user/tenant ID; respects
privatecaching rules if you opt-in.
When in doubt, default to public for anonymous pages and no-cache/private for authenticated content until you design per-user caching.
Controlling cacheability in handlers#
Use headers to guide the middleware:
return Response::json($data)
->withHeader('Cache-Control', 'public, max-age=60'); // cache for 60s
Opt-out:
return Response::json($data)
->withHeader('Cache-Control', 'no-store');
Short-lived private cache (if enabled in middleware):
return Response::json($data)
->withHeader('Cache-Control', 'private, max-age=10');
Example#
Cached product page#
Route::get('/products/{id:int}', function (int $id) {
$product = repo()->getProduct($id); // DB call
return Response::json($product)
->withHeader('Cache-Control', 'public, max-age=120');
}, 'products.show');
Subsequent GETs within 120s hit the cache (no DB). After that, first request recomputes and refreshes the entry.
Manual purge (pseudo)#
// after admin updates a product
cache()->purgeByTag("product:{$id}"); // if you tag keys
// or
cache()->purgeKey(routeKey('products.show', ['id'=>$id], $query, $media));
Configuration knobs (typical)#
store– PSR-16/PSR-6 cache adapter or custom storedefaultTtl– seconds when response lacks explicitmax-agerespectCacheControl– honorno-store/private(default true)varyBy– list of attributes to include in keys (media,locale,encoding)includeAcceptEncodingInKey– true if storing pre-compressed bodiesprivateKeyResolver– callable to includeuserId/tenantIdin keys for private cachesmaxObjectSize– guardrail for memory stores
(Match names to your implementation.)
Observability#
Expose counters:
response_cache_hits_total{route="..."}response_cache_misses_total{route="..."}response_cache_store_errors_total{...}response_cache_bytes{...}(if you track object size)
Log keys at debug level for tricky bugs.
Troubleshooting#
Symptom |
Likely cause |
Fix |
|---|---|---|
Fresh changes not visible |
Stale cache |
Lower TTLs, add purge on write, or enable SWR |
Wrong variant served |
Missing |
Include |
Auth data leaked |
Using public cache for private content |
Mark responses |
No caching happening |
|
Remove |
Double compression |
Storing compressed & compressing again |
Store uncompressed, or if storing compressed, set |
Checklist#
Add ResponseCache early in pre-global (after negotiation)
Choose a key that reflects route + params + query (+ media/locale)
Respect
Cache-Control; set TTLs in handlersCoordinate with ETag/Compression strategies
Plan invalidation on writes (purge by key or tags)
Monitor hit/miss ratios and adjust TTLs