Many API responses change far less often than they are requested. A list of countries, a product catalogue or yesterday’s exchange rates may be requested thousands of times between changes, and each request repeats the same database queries to produce the same result. The Response Cache middleware stores those responses and serves them again without running the route.
The middleware is free and MIT-licensed. It was listed on the public roadmap.
It follows HTTP’s caching rules
HTTP already defines how responses are cached, in RFC 9111. The middleware follows those rules, so a route controls caching with the same Cache-Control header that browsers and content delivery networks read:
return Http\Response::json($response, $countries, 200)->withHeader('Cache-Control', 'public, max-age=3600');
A response is stored for as long as its s-maxage, max-age or Expires header allows. A route that sets no header can be given a lifetime when the middleware is attached to it. Each response carries an Age header and an X-Cache header stating whether it came from the cache.
A response that varies by a request header, such as the language requested, lists that header in Vary, and the cache keeps a separate copy for each value.
It does not store what it should not
A shared cache that serves one customer’s data to another is a security incident. The middleware therefore never stores responses marked no-store, no-cache or private, responses that set a cookie, or responses that vary on every header.
A response to an authenticated request is not stored unless it states that it may be shared. A request counts as authenticated when it carries an Authorization header, or when Kipchak Identity identifies its consumer, for example from an API key. For APIs whose responses differ by customer, the cache can instead keep a separate copy for each consumer, so that each customer receives only their own responses.
ETags and conditional requests
The middleware adds an ETag to each successful response that does not have one. A client that already holds a copy can send that ETag in an If-None-Match header, and receives 304 Not Modified with no body if the response has not changed. This saves the transfer even for routes that are not cached, because the comparison is made after the route has run.
Invalidation when data changes
A cached response must be removed when the data it contains changes. A successful POST, PUT, PATCH or DELETE removes every stored response for its path, and for the path in its Location header, as RFC 9111 specifies. Changes made elsewhere, for example by a queue consumer, can remove responses by tag:
// When serving an order list:
return $response->withHeader('Cache-Control', 'public, max-age=300')->withHeader('Cache-Tag', "user-{$userId} orders");
// After a change made elsewhere:
ResponseCache::invalidateTags("user-{$userId}");
Invalidation takes effect at once for every worker and server that shares the cache. Testing in worker mode found that the caching library used underneath keeps its record of tag versions for 150 milliseconds in each worker, so another worker could serve a response that had just been invalidated. The middleware disables that behaviour. In the final test, 1,280 reads made immediately after 40 writes across eight workers returned no outdated response.
Built on Kipchak’s drivers
Responses are kept with Kipchak’s Filecache driver by default, which shares them between the workers on one server. To share them between servers, they can be kept with the Memcached driver, or with the Enterprise Valkey driver. If the store becomes unavailable, requests are served without the cache, and the error is logged once a minute.
In testing, 4,000 requests for 50 items ran the route 53 times.
Installation
The Response Cache middleware is free and MIT-licensed:
composer require kipchak/middleware-response-cache
It can cache every route, or be attached to individual routes and groups. The configuration reference, including how clients’ own Cache-Control headers are treated, is in the Response Cache middleware documentation.