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

store

CacheInterface

required

PSR-16 cache implementation

ttlSeconds

int

10

Default TTL for cached responses

includeQuery

bool

true

Include query string in cache key

maxBodyBytes

int

1048576

Maximum response body size to cache (bytes)

defaultVary

array<string>

['Accept', ...]

Default Vary dimensions to include in key

skipWhenPersonalized

bool

true

Skip caching if response varies by user

respectResponseCacheControl

bool

true

Honor Cache-Control directives from response

avoidSetCookie

bool

true

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#

  1. HTTP Method: GET, HEAD (others not cached)

  2. Host + Path: /users/42 on api.example.com

  3. Query String (if includeQuery: true): ?page=2&sort=name

  4. Negotiated Content:

    • Media type: application/json

    • Charset: utf-8

    • Locale: en

    • Encoding: br (if cached post-compression)

  5. Vary Surface: Actual header values for Vary dimensions

Not Included (by design):

  • Request headers like User-Agent, Referer

  • Cookies (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=... or s-maxage=...

Not cached:

  • Set-Cookie responses (unless you explicitly allow private caching)

  • Cache-Control: no-store or private (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-age or 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.


Cooperation with ETags & Compression#

  • If a cached entity includes an ETag, the cached response should store it.

  • If you cache already-compressed bytes per encoding, key must vary by Accept-Encoding (or store uncompressed and let Compression run afterward—trade CPU vs memory).

  • Using Compression with recompute-strong ETag strategy: you may cache post-compression bytes; validators and caches stay precise.


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 private caching 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 store

  • defaultTtl – seconds when response lacks explicit max-age

  • respectCacheControl – honor no-store/private (default true)

  • varyBy – list of attributes to include in keys (media, locale, encoding)

  • includeAcceptEncodingInKey – true if storing pre-compressed bodies

  • privateKeyResolver – callable to include userId/tenantId in keys for private caches

  • maxObjectSize – 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 Vary dimensions in key

Include media / locale / Accept-Encoding as appropriate

Auth data leaked

Using public cache for private content

Mark responses private and include user/tenant in key or disable caching

No caching happening

no-store or store misconfigured

Remove no-store, set defaultTtl, verify cache adapter

Double compression

Storing compressed & compressing again

Store uncompressed, or if storing compressed, set includeAcceptEncodingInKey=true and don’t re-compress


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 handlers

  • Coordinate with ETag/Compression strategies

  • Plan invalidation on writes (purge by key or tags)

  • Monitor hit/miss ratios and adjust TTLs