A Laravel package that adds a smart cache for full responses
Using this package, you can cache full response instances returned from your controllers, simply by adding a middleware.
This can speed up your application immensely!
- Easy to use: Just add the
CacheResponseto your route! - Fully customizable: The cache probably works out of the box, but you can control every aspect of caching if necessary.
- Support for authenticated requests: By default, cache entries will be unique to every authenticated user.
- Resolves route bindings in tags: Cache tags may use any value in the route or request data dynamically.
Route::get('/example')->middleware([
'response_cache:3600,examples'
])Coincidentally, I just learned spatie have built almost exactly the same library as we did:
spatie/laravel-responsecache.
In contrast to external caches, having the response cache in your app gives you the power to evaluate route bindings, authentication state, and easy programmatic cache flushing.
This also makes it possible to place the cache check anywhere in your middleware stack!
- PHP 8.2 or later
- Laravel 12.x or 13.x
Install using composer:
composer require matchory/response-cacheUnless you have disabled package discovery, everything should be set up already. Otherwise, add the service provider and the facade to your configuration.
Optionally, you can publish the configuration file:
php artisan vendor:publish --provider "Matchory\ResponseCache\ResponseCacheProvider"Check out the configuration section to learn about all available options.
Register the middleware aliases in your bootstrap/app.php:
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'response_cache' => \Matchory\ResponseCache\Http\Middleware\CacheResponse::class,
'bypass_cache' => \Matchory\ResponseCache\Http\Middleware\BypassCache::class,
]);
})Then add it to your routes, after authentication:
Route::middleware(['auth:sanctum', 'response_cache'])->group(function () {
// ...
});Important: The cache must run after authentication. Cached responses are returned before any middleware that follows
response_cacheruns, and cache entries are only separated per user if the user is already authenticated when the cache is checked. Placing it before your authentication or authorization middleware can serve one user's responses to another.
The config file contains the following settings:
Using this setting, you can globally enable or disable the response cache for all requests. If you set it to false, nothing will be cached.
Defaults to true.
This setting specifies the amount of time responses will be cached before being marked as stale. The value must be specified in seconds.
Defaults to 60 * 60 * 24, or exactly 24 hours.
Here you may pass any cache store defined in your config/cache.php file. Tags work on every store, including file, database and dynamodb; the store
does not need to support Laravel's cache tags. The failover store is not supported, because it can lose flushes during an outage (see
Limitations).
Defaults to null, which uses your application's default cache store.
Every tag has a version, which is replaced when the tag is flushed (see Cache Tags). This setting specifies how long versions are kept, in
seconds. Versions are never refreshed: once one expires, all responses carrying that tag become misses, and the next request starts a new version. Cached
responses therefore never outlive tag_ttl, even if their own TTL is longer, and responses cached without a TTL expire after tag_ttl.
Defaults to 60 * 60 * 24 * 30, or 30 days.
Tags added to every cached response. Additional tags may be used depending on the middleware and strategy. Flushing one of the default tags invalidates every cached response.
Defaults to [].
If the server timing option is enabled, a Server-Timing header containing the initial caching time will be added to all cached responses. This makes debugging easier. Leaving it enabled in production probably has no negative consequences aside from a small performance penalty due to the bigger response size.
Defaults to false.
If the cache status option is enabled, a Response-Cache-Status header containing the cache status (hit or miss) will be added to all responses. This makes debugging easier.
Defaults to true.
After following the installation instructions above, you should be ready to go: Responses with a status code in the 200 and 300 range will be cached and
returned on subsequent visits. To bypass the cache for specific routes, you can simply add the bypass_cache middleware:
Route::get('/kitten')->middleware('bypass_cache');To clear the cache manually, you can use the response-cache:clear artisan command (or its alias, response-cache:flush):
php artisan response-cache:clearThis will flush the entire response cache, and nothing else in the cache store. To flush a given set of cache tags only, add them to the command:
php artisan response-cache:clear tag-1 tag-2 ... tag-nIn production use, it is often helpful to flush the cache automatically if something happens. For example, you can flush the cache whenever a model event is fired by setting up an observer:
use Matchory\ResponseCache\Facades\ResponseCache;
class ExampleObserver {
public function saved(): void {
ResponseCache::flush();
}
public function deleted(): void {
// By passing one or more tags, only those items tagged appropriately
// will be cleared
ResponseCache::flush(['example']);
}
}Calling flush() without arguments (or with null) invalidates every cached response. Passing tags invalidates every response carrying at least one of
them, regardless of its other tags, the user it was cached for, or its query string. Passing an empty array flushes nothing, so a computed tag list that
happens to be empty can never clear the whole cache.
Flushing is cheap: it writes one small cache key per tag, so it's safe to call many times from observers. Invalidated responses are not deleted; they stay in
the store until their TTL expires. If you need the space back immediately, run php artisan cache:clear on that store.
Flushes run after database transactions end. If you flush while a transaction is open, the flush is deferred until every open transaction, on every connection, has ended, so other requests can't cache uncommitted data in the meantime. The flush runs after a rollback, too, since the data may have been written outside that transaction or on another connection; that only costs some cache misses. Outside a transaction, the flush runs immediately.
If the cache store fails to write a flush, flush() throws a RuntimeException. A deferred flush runs while Laravel finishes the transaction, so a failure
there is reported to your exception handler instead, and the rest of your after-commit callbacks still run.
To be more selective, use the delete method:
use Matchory\ResponseCache\Facades\ResponseCache;
ResponseCache::delete('/example/url');
ResponseCache::delete(['/example/1', '/example/2']);Note: This only deletes the response cached for the current context, such as the authenticated user, because cache keys include it. To clear a URL for everyone, work with tags instead.
Cache tags are a simple, yet very powerful mechanism for efficient cache usage. By applying tags to a set of cached items, you can purge all of them, without ever knowing the individual cache keys of these items. Say, there are lots of cached data points all related to "flights": plane schedules, arrival dates, passenger records, etc. All is fine and well, until a flight is cancelled! Now, we will need to purge the cache for anything that includes the flight in question. This would be a logical nightmare, hadn't we set up cache tags: Instead of deleting lots of items manually, we flush the tag with the flight number, and we're good to go!
Tags work on every cache store; they don't use Laravel's cache tags. Instead, every tag has a version: a random token stored in the cache. Each cached response records the versions of its tags when it is built, and it is only served while all of them are still current. Flushing a tag writes a new version, which makes every response carrying it a miss. The versions are read before your controller runs, so a flush that happens while a response is being built invalidates that response, too.
This library is built heavily around the concept of cache tags: By tagging properly, you can make granular cache flushing a breeze. Tags can be added to routes using several approaches:
- By passing them to the middleware:
Route::get('/foo')->middleware(['response_cache:tag-1,tag-2']);
- By adding them to the
config/response-cache.phpconfig file:'tags' => env('RESPONSE_CACHE_TAGS', [ 'tag-1', 'tag-2' ]),
- By returning them from your strategy.
This library supports adding dynamic tags that are based on route bindings. This works pretty much the same as with ordinary route bindings, but with the added
benefit of also having access to all request values, not just those mentioned in the route itself.
To add a binding to your cache tag, simply include the name of a parameter in curly braces: flights.{flight}. As soon as something is to be fetched from or
put into the cache, this binding will be resolved using the current request instance. If the parameter flight was an instance of an Eloquent model, it would
be replaced with its route key name, so probably the flight ID!
Take a look at the following example:
Route::get('/api/v{version}/flights/{flight}', function(\App\Flight $flight) {
// ...
})->middleware('response_cache:api.v{version},flights,flights.v{version},flights.{flight},flights.v{version}.{flight}');Imagine we perform the following request: /api/v3/flights/505
Now, the response generated by this route would be tagged with the following tags:
api.v3flightsflights.v3flights.505flights.v3.505
Depending on your requirements, you can simply flush the cache for any of these and rest assured the response will be invalidated!
A strategy is what the response cache uses to determine just how a response should be cached. It generates cache keys, determines cache tags and controls cache bypassing. The default strategy should fit for most use cases, but if it doesn't, we got you covered, too!
To use a custom strategy, start by either extending the default implementation (recommended) or
implementing the CacheStrategy interface.
To customize the way cache keys are generated, you have several options, as the default implementation splits this process in multiple methods:
use Illuminate\Http\Request;use Matchory\ResponseCache\Support\BaseStrategy;
class MyStrategy extends BaseStrategy
{
public function key(Request $request): string
{
$identifier = $this->extractRequestIdentifier($request);
$suffix = $this->buildSuffix($request);
return $this->hash($identifier . $suffix);
}
}- The
extractRequestIdentifiermethod extracts the full request URL and method as the base of the cache key. This should be enough in most applications. - The
buildSuffixmethod checks for the current authentication status and appends the ID of the authenticated user. You may wish to modify this to use a customer or application identifier, for example. - The
hashmethod builds a hash of the given cache key (by default it usesmd5), so the key length stays consistent.
Strategies decide in two steps whether to cache. Before the response exists, isCacheable decides whether a request may be served from or stored in the
cache at all. Requests it rejects skip the cache entirely, without reading anything. By default, it accepts GET and HEAD requests:
use Illuminate\Http\Request;
use Matchory\ResponseCache\Support\BaseStrategy;
class MyStrategy extends BaseStrategy
{
public function isCacheable(Request $request): bool
{
return $this->isMethodCachable($request);
}
}Once the response exists, shouldCache decides whether to store it:
use Illuminate\Http\Request;
use Matchory\ResponseCache\Support\BaseStrategy;
use Symfony\Component\HttpFoundation\Response;
class MyStrategy extends BaseStrategy
{
public function shouldCache(Request $request, Response $response): bool
{
if (! $this->isCacheable($request)) {
return false;
}
return $this->isSuccessful($response);
}
}By default, this will cache any request with the GET or HEAD methods, and responses with a success or redirection status.
Tagging cached responses is an immensely powerful feature that allows you to flush a collection of cache entries if something happens. So if a single member of
a collection is deleted, for example, you can remove all cached instances of the same collection, without having to know the exact cache keys used to retrieve
them!
To make this as easy as possible, strategies provide a method to pull tags from a request. Tags must be derivable from the request alone, because they
are needed to look up a cached response before any response exists:
use Illuminate\Http\Request;
use Matchory\ResponseCache\Support\BaseStrategy;
class MyStrategy extends BaseStrategy
{
public function tags(Request $request): array
{
return [
$request->attributes->get('customer.id')
];
}
}When adding the middleware to routes, you also have the possibility to configure the time to leave and add a set of tags per route:
// Default settings as set in the config file
Route::get('/foo')->middleware('response_cache')
// TTL of 60 seconds
Route::get('/bar')->middleware('response_cache:60');
// Tags "foo", "bar" and "baz" are added
Route::get('/baz')->middleware('response_cache:foo,bar,baz');
// TTL of 60 seconds and tags "foo", "bar" and "baz" are added
Route::get('/quz')->middleware('response_cache:60,foo,bar,baz');If the first parameter to the middleware is a number, it will be interpreted as a TTL value, everything that follows as tags. The TTL will override the configuration value, tags will be merged.
This library exposes some events you can listen for and act accordingly. This is probably most helpful during debugging.
Emitted if a response was found in the cache. Includes the request instance.
Emitted if a response could not be found in the cache and thus had to be generated by the application. Includes the request instance. Requests the
caching strategy rejects in isCacheable, such as POST requests, bypass the cache without emitting any event.
Emitted if the cache was flushed. Includes the tags that were flushed, or null if all tags were flushed.
Cache issues can be a little annoying to debug. This library has several facilities built in to make the process as simple as possible:
- Enable the
Server-Timingheader: By switching this feature on, all responses will include aServer-Timingheader that includes the time your response was cached. This information will automatically show up in your browser's developer tools! - Listen to cache events: By listening to the cache events, you can make sure everything is working as intended.
- Temporarily or conditionally disable caching: By changing the
response-cache.enabledsetting, you can quickly determine whether caching is the culprit.
Tag versioning never serves a response after a flush that it knows about, but some situations are outside its control:
- Data read before the versions. The cache middleware runs after authentication and route binding, so a bound model or the authenticated user is loaded before the tag versions are read. A write that commits and flushes in those few milliseconds produces a response built from the old data but stamped with the new version. It is served until the next flush of one of its tags, or until its TTL expires.
- Read replicas. A request that reads from a lagging replica after a flush can cache old data under the new version, just like above.
- Store replication and failover. If the cache store loses a write, for example during a Redis failover with asynchronous replication, or when
Memcached remaps a dead server, an old version can come back and make stale responses valid again. This is inherent to those stores. For the same reason,
Laravel's
failovercache store is refused. - DynamoDB reads are eventually consistent, which leaves a short window after a flush. The version of the internal global tag is read on every request, which makes it a hot partition.
- Local stores. The
fileandapcstores are local to a host, and thearraystore to a process. A flush only reaches the host or worker that runs it, so use them on a single host only. - Failed commits. Laravel runs no after-commit or rollback callbacks when a commit itself fails, and none after an earlier callback of the same transaction throws. A flush deferred in such a transaction is skipped, just like your other after-commit callbacks. A failed commit normally means the database rolled the transaction back, so nothing changed; only a connection lost during the commit leaves it unknown whether the data was written.
- Hit rate. Flushing a broad tag like
userson every write keeps all responses carrying it cold on write-heavy tables, and flushing everything after a deployment causes a burst of misses. The same burst happens everytag_ttl(30 days by default) after the first response was cached, when the version of the internal global tag expires. There is no lock against many requests rebuilding the same response at once.
Version 2 replaces Laravel's cache tags with tag versioning. Existing cache entries are not read anymore, which just causes misses after the upgrade.
- Strategies:
CacheStrategy::tags()takes the request only:tags(Request $request): array. Tags derived from the response are no longer supported. - Strategies:
CacheStrategyhas a new method,isCacheable(Request $request): bool. Custom strategies extendingBaseStrategyinherit it. - Events: requests rejected by
isCacheable(by default, anything butGETandHEAD) no longer emit aMissevent. - Flushing:
ResponseCache::flush($tags)no longer adds the default tags, so it flushes exactly the given tags.flush([])now does nothing; useflush()to flush everything. - Flushing: flushes while a database transaction is open run once all open transactions have ended, whether they commit or roll back.
- Flushing:
flush()throws aRuntimeExceptionif the cache store fails to write. Failures of deferred flushes are reported to the exception handler. - Flushing: invalidated responses are no longer deleted from the store; they expire with their TTL.
ResponseCachemethods:has()was removed; useget()orlookup().put()requires aSnapshotof the tag versions, taken before the response is built, in place of the tags:put($request, $response, $snapshot, $ttl). Take one withsnapshot($request, $tags), or use the one returned bylookup($request, $tags)on a miss. Either isnullif the tag versions could not be established; don't cache the response then.get()andlookup()take a tags array ([]by default) instead ofnull.delete()no longer takes tags.- Cached responses: responses are stored as their status, headers and content, so caching works even if
serializable_classesis restricted inconfig/cache.php(the default in Laravel 13). A cached response is returned as anIlluminate\Http\Responsewith the same status, headers and content, whatever class the original response had. Responses without content, such as streamed responses and file downloads, are not cached. Repository:has(),getStore(),supportsTags()andserialize()were removed, along with custom serialization through aRepositorysubclass.get(),put()anddelete()have new signatures. Its constructor takes the tag TTL as a third argument.- Configuration:
storedefaults tonull(your default cache store) instead offile. The newtag_ttloption sets how long tag versions are kept (30 days by default) and must be at least one second. Thefailovercache store is refused with anInvalidArgumentException. - Stores: tags work on every cache store; a store with tag support is no longer needed.