Laravel Response Cache v8 is here: now offers flexible caching original

by Freek Van der Herten – 6 minute read

Our laravel-responsecache package speeds up your app by caching entire responses on the server. When the same page is requested again, the cached response is served without hitting your controller at all.

We just released v8, a new major version with a powerful new feature: flexible caching. It uses a stale-while-revalidate strategy, so that every visitor gets a fast response, even when the cache is being refreshed.

Let me walk you through it.

Caching responses

The basic usage hasn't changed. Add the CacheResponse middleware to a route, and the full response gets cached:

use Spatie\ResponseCache\Middlewares\CacheResponse;

Route::get('/posts', function () {
    return view('posts');
})->middleware(
    CacheResponse::for(minutes(30))
);

Every request within those 10 seconds gets the stored response instantly. Your controller doesn't run at all. Depending on the complexity of your page, this can greatly increase the performance of your app.

Flexible caching

Regular caching works great, but it has one downside. When the cache expires, the next visitor has to wait while the server generates a fresh response.

BROWSER SERVER CACHE SERVER request cache expired, generate fresh response browser waits... fresh response fresh response

That visitor is the unlucky one. On a page that takes a while to render, say a dashboard with complex queries, their experience is noticeably slower.

Flexible caching solves this with the FlexibleCacheResponse middleware:

use Spatie\ResponseCache\Middlewares\FlexibleCacheResponse;

Route::get('/dashboard', function () {
    return view('dashboard');
})->middleware(
    FlexibleCacheResponse::for(
        lifetime: minutes(10),
        grace: minutes(5),
    )
);

There are two parameters: a lifetime and a grace period.

Fresh (lifetime) Grace Expired 0 10 min 15 min Served from server cache Controller doesn't run Stale sent to browser Controller runs via defer No cache Browser waits

During the lifetime, responses are served from the server cache, just like regular caching. When the lifetime expires, instead of leaving visitors hanging, the grace period kicks in.

The grace period

This is the key part. When a request arrives during the grace period, two things happen simultaneously:

BROWSER SERVER CACHE SERVER request stale response — instant! defer: regenerate fresh result cached later — next visitor request fresh response — instant!

The stale response is sent to the browser immediately. The visitor doesn't wait at all. At the same time, using Laravel's defer, the server runs your controller after the response is already sent. The fresh result gets stored in the cache.

The next visitor gets the fresh response, also instantly.

Every visitor gets a fast page. The regeneration happens in the background, invisible to your users.

Regular vs flexible

Regular cache Cached on server Expired browser waits Flexible cache Cached on server Grace Expired still instant! server regenerates via defer

With regular caching, the first request after expiry is slow: the browser has to wait for the server. With flexible caching, the grace period acts as a safety net. Stale content is served instantly while the server regenerates a fresh response in the background via defer.

Only when the cache is completely gone, past both lifetime and grace, does a visitor have to wait. On a page with regular traffic, this rarely happens.

Replacers

When you cache an entire response, some parts of the HTML might need to stay dynamic. Think of CSRF tokens: every user session needs a fresh one, but the rest of the page can stay cached.

Replacers solve this. Before storing a response, a replacer swaps dynamic content with a placeholder. When serving the cached response, it replaces the placeholder with a fresh value. The package ships with a CsrfTokenReplacer out of the box, so forms just work.

You can create your own replacer by implementing the Replacer interface:

use Spatie\ResponseCache\Replacers\Replacer;
use Symfony\Component\HttpFoundation\Response;

class UserNameReplacer implements Replacer
{
    protected string $placeholder = '<username-placeholder>';

    public function prepareResponseToCache(Response $response): void
    {
        $content = $response->getContent();
        $userName = auth()->user()?->name ?? 'Guest';

        $response->setContent(str_replace(
            $userName,
            $this->placeholder,
            $content,
        ));
    }

    public function replaceInCachedResponse(Response $response): void
    {
        $content = $response->getContent();
        $userName = auth()->user()?->name ?? 'Guest';

        $response->setContent(str_replace(
            $this->placeholder,
            $userName,
            $content,
        ));
    }
}

Then register it in the config:

// config/responsecache.php

'replacers' => [
    \Spatie\ResponseCache\Replacers\CsrfTokenReplacer::class,
    \App\Replacers\UserNameReplacer::class,
],

This way you can cache the full page while keeping small parts of it personalized per user or per session.

Using attributes

Instead of applying middleware in your routes file, you can use PHP attributes directly on your controllers. Put #[Cache] on a class to cache all its methods, or on a specific method to cache just that one.

use Spatie\ResponseCache\Attributes\Cache;
use Spatie\ResponseCache\Attributes\NoCache;

#[Cache(lifetime: 5 * 60)]
class PostController
{
    public function index()
    {
        // cached for 5 minutes
    }

    #[NoCache]
    public function store()
    {
        // not cached
    }
}

The #[NoCache] attribute lets you opt out specific methods when the rest of the controller is cached. This is useful for write operations like store or update.

Flexible caching has its own attribute too:

use Spatie\ResponseCache\Attributes\FlexibleCache;

class DashboardController
{
    #[FlexibleCache(lifetime: 3 * 60, grace: 12 * 60)]
    public function index()
    {
        return view('dashboard');
    }
}

I like this approach because caching behavior lives right next to the code it applies to. No need to check the routes file to figure out what's cached and what isn't.

Server-side caching vs Cloudflare

You might be wondering: why not just use Cloudflare?

Cloudflare is great. It serves cached pages from edge nodes close to the visitor. Your server doesn't even see the request. For public pages like a marketing site or docs, that's perfect.

But Cloudflare doesn't know about your app. It caches at the URL level. It has no idea who's logged in, what role they have, or what session data exists. If you need different responses per user, you're stuck wrestling with Vary headers and cache keys.

Our package runs inside your Laravel app, so it has access to everything. The authenticated user, request parameters, custom cache profiles. You can cache a page differently for admins and guests. You can invalidate the cache when a model changes. You can use replacers to keep parts of a cached response dynamic.

You can also use both. Cloudflare for your fully public pages, laravel-responsecache for anything that needs application-aware caching. They work well together.

In closing

Flexible caching is the headline feature of v8. For most apps, adding a grace period is a no-brainer: you get the same caching benefits with zero slow requests during regeneration.

You can find the code of the package on GitHub. We also have extensive docs on our website.

This is one of the many packages we've created at Spatie. If you want to support our open source work, consider picking up one of our paid products.

Join 9,500+ smart developers

Every month I share what I learn from running Spatie, building Oh Dear, and maintaining 300+ open source packages. Practical takes on Laravel, PHP, and AI that you can actually use.

No spam. Unsubscribe anytime. You can also follow me on X.

Found something interesting to share? Submit a link to the community section.