Most traffic on a content website comes from anonymous readers opening the home page, category pages and article pages. For every one of those requests, Laravel still boots the framework, runs dozens of queries and renders Blade, even though the result is almost identical to the previous one. This guide shows how to use Laravel ResponseCache (the spatie/laravel-responsecache package) to store the full HTML response, move query caching to Redis and hand queue workers over to Supervisor: sample configuration, how to clear the cache when content changes, common errors, and a checklist for enabling the cache in production.

1. Two cache layers in a Laravel application

Two things often lumped together as "cache" need to be kept apart:

Query / data cacheResponse cache (Laravel ResponseCache)
What it storesQuery results, data arrays (Cache::remember())The whole HTTP response: headers and rendered HTML
Work left on a cache hitStill runs the controller and renders the viewOnly boots the framework, runs the middleware and returns immediately
RiskStale data in a small blockA whole stale page, or one user's content leaking to another

The two layers complement each other. Laravel ResponseCache delivers the biggest gain on public pages, while query caching is still needed where a whole page cannot be cached.

2. How Laravel ResponseCache works

Laravel ResponseCache registers a middleware. When a request passes through, the middleware asks the cache profile two questions: should this request be cached, and should this response be stored? If so, the response is serialized and written to Laravel's cache store (file, Redis…) under a key that is a hash of the URL, the method and a suffix chosen by the profile. Later requests with the same key get the stored response back without running the controller. The full documentation is on Spatie's laravel-responsecache documentation.

The example below uses Laravel 8. Install the package and register the middleware as route middleware so it can be enabled per route:

composer require spatie/laravel-responsecache:^7.4 predis/predis
php artisan vendor:publish --provider="Spatie\ResponseCache\ResponseCacheServiceProvider"

// app/Http/Kernel.php
protected $routeMiddleware = [
    // ...
    'cache-response'        => \Spatie\ResponseCache\Middlewares\CacheResponse::class,
    'do-not-cache-response' => \Spatie\ResponseCache\Middlewares\DoNotCacheResponse::class,
];

predis/predis is a Redis client written in pure PHP, so it works even when the minion does not have the phpredis extension installed. Pin versions to a major release (such as ^7.4) and avoid @dev, which lets composer pull any build on the next update.

3. Configuring Laravel ResponseCache for a content website

Config file: toggled by environment variables

// config/responsecache.php (excerpt)
'enabled'                   => env('RESPONSE_CACHE_ENABLED', true),
'cache_profile'             => App\Cache\SiteResponseCacheProfile::class,
'cache_lifetime_in_seconds' => env('RESPONSE_CACHE_LIFETIME', 60 * 5),
'add_cache_time_header'     => env('RESPONSE_CACHE_DEBUG_HEADER', false),
'cache_store'               => env('RESPONSE_CACHE_DRIVER', 'redis_response'),
'cache_tag'                 => 'responsecache',
'replacers'                 => [
    \Spatie\ResponseCache\Replacers\CsrfTokenReplacer::class,
],

Every important setting is read from .env: on/off, lifetime, store, debug header. The .env.example file should set RESPONSE_CACHE_ENABLED=false so development environments do not cache by default and only production turns it on. When you suspect the cache is causing a bug, you can turn Laravel ResponseCache off with one line in .env and php artisan config:cache, without redeploying code.

Cache profile: deciding what gets cached

The default Laravel ResponseCache profile caches every successful GET request. For a website with an admin area and logged-in users, that is far too broad. A sample profile:

class SiteResponseCacheProfile extends BaseCacheProfile
{
    public function shouldCacheRequest(Request $request): bool
    {
        if ($request->ajax())                 return false;
        if ($this->isRunningInConsole())      return false;
        if (Auth::check())                    return false;  // logged-in user
        if (Auth::guard('admin')->check())    return false;  // administrator

        return $request->isMethod('get');
    }

    public function shouldCacheResponse(Response $response): bool
    {
        if (! ($response->isSuccessful() || $response->isRedirection())) return false;

        // only cache HTML/text, never JSON
        return Str::startsWith($response->headers->get('Content-Type', ''), 'text/');
    }

    public function useCacheNameSuffix(Request $request): string
    {
        // mobile and desktop layouts differ, so keep separate cache copies
        return deviceType();   // 'mobile' or 'desktop'
    }
}

The Laravel ResponseCache useCacheNameSuffix() suffix lets one URL have several cached copies, which is necessary when the website renders different HTML for mobile and desktop. Keep the number of variants as small as possible: every extra suffix is one more copy to clear when content changes (see section 5).

Attaching the middleware per route (shortened)

Route::get('/', 'HomeController@index')->middleware('cache-response');
Route::get('/tag/{slug}', 'TagController@index')->middleware('cache-response');
Route::get('{slug}', 'HandleController@index')            // articles, categories
    ->where('slug', '^(?!admin\/|ajax\/|sitemap\/)[a-zA-Z0-9-._\/]+$')
    ->middleware('cache-response');

Route::get('/contact', 'ContactController@index')->middleware('do-not-cache-response');
Route::post('/ajax/contact', 'AjaxController@sender')->middleware('do-not-cache-response');

Only public pages get Laravel ResponseCache. Pages with forms, post-submit notices and every POST endpoint are explicitly marked as not cached, so whoever reads the code later understands it was a deliberate decision, not an oversight.

4. Clearing the cache when content changes

A cache is only useful when it is correct. An editor fixing an article while readers keep seeing the old version for five minutes is a business bug. Every time an article is created or updated, the admin controller should clear the query cache and the Laravel ResponseCache copy for exactly that URL, for every suffix:

use Spatie\ResponseCache\Facades\ResponseCache;

// in PostController@update, after saving to the database
Cache::store('redis')->forget('post_' . $id);
Cache::store('redis')->forget('post_slug_' . $slug);

foreach (['mobile', 'desktop'] as $suffix) {      // every suffix the profile can produce
    ResponseCache::selectCachedItems()
        ->usingSuffix($suffix)
        ->forUrls('/' . $slug)
        ->forget();
}

The same mechanism applies to comments: approving, replying to, hiding or deleting a comment all clear the cache of the article it belongs to. The comment list cache should use remember(..., 60 * 30) rather than rememberForever: if you forget to clear it somewhere, stale data still expires on its own.

Query cache on Redis

Laravel ResponseCache handles the HTML, but the underlying data still needs its own cache:

Cache::store('redis')->remember($type . '_' . $typeId, 60 * 30, function () use ($type, $typeId) {
    return Comment::where('type', $type)->where('type_id', $typeId)->get();
});

Compared with the file cache, Redis lets many PHP-FPM processes read and write at the same time without fighting over files on disk, deletes a key with low latency, and can be given a maxmemory setting so the cache does not eat all the RAM on the Cloud VPS. Configure the connection according to the Laravel 8 Redis documentation, using one Redis database for the query cache and a separate one for Laravel ResponseCache.

5. Common errors with Laravel ResponseCache

Clearing only one variant

The most common bug: computing the suffix from the current request when clearing, which is the request of the editor clicking save. If the editor is on desktop, only the "desktop" copy is cleared, and readers on phones keep seeing the old article until the cache expires. This bug is hard to spot during self-testing, because the tester usually uses the same device they edited on. Always loop over every suffix as in section 4, or use a store that supports tags and clear by tag.

Accidentally caching pages with sessions, CSRF tokens or notices

If a response containing a CSRF token or a "Message sent successfully" notice gets cached, the next visitor receives the previous visitor's token and the form fails with a 419 error. CsrfTokenReplacer swaps the token for the current session's value every time a cached response is served. Flash messages and per-user content have no replacer to handle them, so they must be excluded in the profile (Auth::check()) or on the route (do-not-cache-response).

Wiping the entire cache without realizing it

When cache_tag is not set, php artisan responsecache:clear flushes the entire cache store that Laravel ResponseCache uses. If that store also holds the query cache, everything is wiped at once, and the very next wave of visits lands entirely on MySQL. Use a separate store or Redis database for Laravel ResponseCache.

Not knowing whether a request hit the cache

Temporarily enable the cache-time header with its own variable (RESPONSE_CACHE_DEBUG_HEADER) instead of turning on APP_DEBUG in production, then compare two consecutive curl -I calls.

6. Queue workers run by Supervisor

Some work after content is saved, such as calling a CDN purge API, should be pushed onto a queue and delayed by a few seconds (dispatch(...)->delay(3)) so the editor's request does not have to wait. That requires queue:work running continuously in the background; Laravel recommends Supervisor, as described in the Supervisor configuration guide in the Laravel 8 documentation. As in the article on the three-tier Cloud VPS model, each site can carry its own supervisord.ini file in its code directory:

[program:example.com-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/example.com/artisan queue:work --sleep=2 --tries=2 --timeout=120 --memory=500
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/var/www/example.com/storage/logs/worker.log

Three common worker problems:

  • Paths still pointing to the old domain: the site was built on a demo domain and later moved to the main domain, but the program name and the artisan path in supervisord.ini still belong to the demo copy. The worker shows "RUNNING" but processes jobs with code from another directory. When a domain changes, the supervisor file must be on the list of things to update.
  • Forgetting to restart after a deploy: a worker is a long-lived process that keeps the old code in memory. After every deploy, run php artisan queue:restart and Supervisor brings the worker back up.
  • QUEUE_CONNECTION=sync: this default in .env.example makes jobs run inside the request, and delay() is ignored. Production must use redis or database.

7. Checklist for enabling Laravel ResponseCache in production

  • Off by default, on by intent: only attach cache-response to public routes.
  • The profile excludes AJAX, logged-in users, administrators and non-HTML responses.
  • CsrfTokenReplacer is in place; pages with forms or flash messages are marked do-not-cache-response.
  • Keep suffixes to a minimum; cache-clearing code loops over every suffix.
  • Always set a TTL: a few minutes for responses, a few tens of minutes for queries.
  • Laravel ResponseCache, the query cache and the queue use different Redis connections or databases; Redis has maxmemory set.
  • Workers run under Supervisor, with queue:restart after every deploy.

Laravel ResponseCache does not replace query or PHP-FPM tuning, but for a content website it is the cheapest way to cut the number of requests that actually reach PHP and MySQL, provided it comes with correct and complete cache invalidation.

Next article

Rebuilding the whole Cloud VPS fleet with cloud images and cloud-init. How a new server is created from a cloud image, what cloud-init does on first boot, and how to determine when a server was really built.