spatie/laravel-responsecache
Laravel package that caches entire responses to speed up your app. By default it stores successful GET responses returning HTML or JSON for a week, serving cached content without hitting the application. Supports flexible stale-while-revalidate caching.
Install and publish config
composer require spatie/laravel-responsecache
php artisan vendor:publish --provider="Spatie\ResponseCache\ResponseCacheServiceProvider"
This drops a config/responsecache.php into your app — that file is the first thing to read. It defines the cache store used, the active CacheProfile, which requests are eligible (GET + successful + text-based content types by default), the replacers, and the debug headers.
Know the default policy before you ship
Out of the box the package caches every successful GET returning HTML/JSON for one week, keyed on the request. That default is only active where you attach the middleware — it is not globally applied for you. Resist adding it to the whole web group on day one.
First use case: a public, read-only listing
use Spatie\ResponseCache\Middlewares\CacheResponse;
Route::middleware(CacheResponse::for(minutes(10)))->group(function () {
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
});
Hit the route twice: the first request writes the cache (deferred to terminate()), the second is served without touching your controller. Enable the debug headers in the config (local only) so you can see hit/miss on the response instead of guessing.
Then wire up invalidation immediately — a cache with no invalidation is a bug factory:
use Spatie\ResponseCache\Facades\ResponseCache;
ResponseCache::forget('/posts');
ResponseCache::clear(); // nuke everything
Tiered lifetimes by route group. Don't pick one TTL for the app. Homepage and marketing pages: hours(6). Listing pages: minutes(10). Detail pages: minutes(2). Attach CacheResponse::for(...) per group rather than globally, so the blast radius of a stale response is bounded and obvious in routes/web.php.
Flexible caching for pages tolerant of staleness. Dashboards, "trending" blocks, and reporting pages are the sweet spot: serve the stale copy instantly, refresh in the background.
use Spatie\ResponseCache\Middlewares\FlexibleCacheResponse;
Route::get('/dashboard', [DashboardController::class, 'index'])
->middleware(FlexibleCacheResponse::for(lifetime: hours(1), grace: minutes(5)));
The stale window is lifetime + grace (a v8.4.2 fix — earlier versions expired at grace). Understand that after lifetime but within grace, someone pays the recompute cost out-of-band.
Attribute-driven config on controllers. For apps where cache policy belongs next to the action, v8 gives you PHP attributes: #[Cache], #[FlexibleCache], #[NoCache]. This is the pattern I'd push for teams — the policy travels with the controller, survives route refactors, and #[NoCache] becomes a readable, greppable opt-out. Use route middleware for cross-cutting groups, attributes for per-action intent.
A custom CacheProfile for house rules. Implement CacheProfile (cacheLifetimeInSeconds(), plus the eligibility hooks) when you need one place that says "cache GETs, skip when authenticated, skip when a flash session exists, skip X-Requested-With." Every route then inherits sane behavior instead of repeating middleware arguments.
Invalidation via model events, not controllers. The reliable pattern is observers:
Post::saved(fn (Post $post) => ResponseCache::forget("/posts/{$post->slug}"));
Post::saved(fn () => ResponseCache::forget('/posts'));
For anything bigger, use tags. Pass tags through CacheResponse::for(lifetime, tags: [...]) and clear a whole content domain with a single call (ResponseCache::clear(['posts'])). This requires a taggable cache store (Redis/Memcached) — the file store will not do it.
Replacers for dynamic fragments in cached HTML. A cached page still needs a live CSRF token and, if you cache per-session-ish pages, the right session identifier. The package ships replacers for these; register your own in the config's replacers array when you have a fragment that must vary (e.g. a localized greeting or a feature-flag badge). This is the extension point people miss — it lets you keep a mostly-static page cached instead of abandoning caching entirely.
Store on terminate() (v8.3+). The response is now captured after the response has passed back through the stack, so post-response middleware (minifiers, debug tooling, header injectors) are included. If you're on an older 7.x, the stored body can differ from what the user actually received.
Console and deploy hooks. responsecache:clear in your deploy script for content-heavy apps; ResponseCache::clear() returns a boolean and dispatches ClearingResponseCacheFailed on failure, so you can alert instead of silently shipping a stale site.
CacheResponse on the web group will happily cache a page containing a user's name and email. Guard with auth-aware logic in your profile, or explicitly #[NoCache] / DoNotCacheResponse on anything behind a login.web group reflexively. Middleware order matters: place caching after things whose effects you want baked in (locale, theme) and never let it swallow auth-dependent middleware output. If it's ambiguous, scope it to a route group.Accept, Accept-Language, API version header, or subdomain. Provide a custom RequestHasher (or normalize the request upstream) rather than discovering this from a bug report about the wrong language.file/database cache silently don't behave. Use Redis/Memcached in production if you plan tag-based invalidation, and make sure the package's store in config/responsecache.php matches.Set-Cookie values. Strip or avoid.responsecache:clear in the deploy pipeline is cheap insurance; use it to avoid "it's fine locally" mysteries.setUp() (or bind an array store) or your feature tests will serve each other's responses. And when asserting controller behavior, remember the second identical request no longer reaches the controller — that's the point, but it breaks naive tests.Event suffix, replaces cacheRequestUntil() with cacheLifetimeInSeconds(), and replaces CacheResponse::using() with ::for(). Grep for those before bumping.How can I help you explore Laravel packages today?