Advanced static cache usage with Statamic and an external Laravel app

Published July 8th, 2024

Before we begin, there are a few things that I’ll cover here:

  1. how to use Statamic’s static caching on a Laravel-based route (i.e. not a Statamic Entry),

  2. a look at a Statamic site and Laravel app architecture pairing, and

  3. how to have the Laravel app tell the Statamic site to clear selected URLs from its static cache

This is advanced usage of Statamic’s full static caching - but one that is working wonders for time-sensitive data, where times are managed outside of the Statamic ecosystem.

Good to go? Onwards…

Full static caching is an incredible feature of Statamic, and can do wonders for your site’s apparent performance for your visitors.

In case you’re new to this, when a page is requested, it will be rendered by Statamic and then a fully-rendered HTML version is placed in a specific folder on the server. Your server - such as Nginx - is configured to first look for a file’s existence, and if not found, hand the request over to a PHP process. Statamic even takes care of not caching things like CSRF tokens, and automatically refreshes those, but through an Ajax request after load (to not hamper initial page response times).

If you’re just in Statamic-land and working with scheduled content - such as blog posts appearing, old content disappearing, etc - you can take advantage of my Scheduled Cache Invalidator addon.

Full static caching is also incredibly useful for computationally-heavy (and time-consuming) pages, such as those that require talking to an API.

One platform I’ve built includes a Statamic-based website, and a Laravel/Inertia-based web portal. It’s for an awards product: the Laravel platform allows the organisation to build, run and manage their annual awards program (called a Season), and the Statamic website presents the Categories, after a specific date and time, the Finalists and Winners.

Static Caching of a Laravel route in Statamic

This gets more interesting too: the routes themselves in Statamic are not actually Entries within Statamic, but just standard Laravel routes with a Controller that pulls from the API, and returns a Statamic\View\View instance. Meaning I can use the site’s layout and template logic, including Antlers.

3namespace App\Http\Controllers;
5class SeasonController extends Controller
7 public function show(string $season)
8 {
9 // do what you need to get, collate and prepare page data
10 // then, return a Statamic View:
11 return (new \Statamic\View\View)
12 ->template('awards/season')
13 ->layout('layout')
14 ->with([
15 'foo' => $bar,
16 ]);
17 }

Before we go on… did you know:

Even though these are just Laravel routes - that happen to return a Statamic view - you can attach the Cache middleware to your routes, and they will follow Statamic’s static caching configuration. Meaning a Laravel-based page can be statically cached by Statamic. You can attach this in your web.php routes definition:

1use Statamic\StaticCaching\Middleware\Cache;
3Route::get('/seasons/{season}', [\App\Http\Controllers\SeasonController::class, 'show'])
4 ->middleware([Cache::class])
5 ->name('awards.season');

Totally awesome, right?

The architecture

For this pairing, the website is for presenting content only - and all business smarts, logic and intelligence is part of the portal, and ensures a DRY approach to the business rules required to show (or not show) awards.

To achieve this, the Statamic website pulls Category information from the Laravel app through its private API, and this API has the business logic required to determine whether the call should return the Categories only, or Category data with Winner information too - and this works a treat, except for two issues:

  1. The API request is a HTTP request - and could theoretically be slow, and

  2. After being cached, the page has no knowledge of whether the winners should be shown

Let’s just illustrate this. Imagine for a moment that Winners should appear at 8:00pm on Friday night. On Thursday, a visitor to the page should see the list of Categories only (without winners). Statamic pulls this via the API, and statically caches the content (#1). Any future requests to the site then use that static HTML version of the page. Come 8:00pm Friday, that static version of the page will still be served (#2), instead of the correct “winners” version of the page (#3).

A flow chart illustrating the issue with static caching and time-sensitive information.

Just to reiterate: the portal in this scenario has the knowledge as to when the Winners should be shown, and Statamic simply displays what it gets given via the API. After the first page request - where it is fully cached - Statamic has no concept of future updates.

Righto so this is a bit of a complex beast too… the Statamic website has a few custom fieldtypes that use data from the Laravel app too, and has some internal caching in place to store these values locally to keep it speedy. To overcome this, I implemented a simple API endpoint on that Statamic site that clears the internal cache, and performs a reload. When either of these Models in the Laravel app saves, a job is dispatched that hits this endpoint to keep the Statamic lookup data current.

Which got me thinking: can I extend this idea to these static pages too?

Given I had created the Scheduled Cache Invalidator addon for Statamic, and had a good grasp on what Statamic exposes to us developers to hook in to its static caching, and this gave me an idea: what if the Laravel app could tell Statamic to clear specific pages from its cache?

And there’s the answer.

Remember the key point: the Laravel app is where the business smarts are. This is where the configuration lives for when Winners are to be visible, so that all of the business logic surrounding Winner visibility is in one place, and fully testable and tested.

We need Laravel to tell Statamic to clear selected pages from the static cache. So when a request is made before Winners are available (#1), that is fully cached. But then at the moment in time when Winners should be visible, Laravel must tell Statamic to clear some items from the cache (#2). Then any future requests will get the new Winner-version of the page (#3).

The solution requires two steps - some work on the Laravel app to tell Statamic to do something, and some work on the Statamic side to clear selected paths from its cache when requested.

The Laravel app

The client is able to configure each area of an awards Season to have Winners made available from any minute of their selected day. So that could be 8:00pm, 2:30am, or even 6:14am because, well, why not.

Laravel and its scheduling capabilities are awesome - and it is trivial to have a command run every minute.

Easy, so all I had to do was write a Command for Laravel that looks at the current time, and gets any of the data that needs to change at that minute. This then pulls the unique Season IDs from the list of data, and dispatches a Job (so the request can be queued) that will call the endpoint on the Statamic site, and pass with it the Season IDs that need to have their content cleared.

3namespace App\Console\Commands;
5use App\Jobs\RefreshWebsiteCacheJob;
6use App\Models\Dates;
7use Carbon\Carbon;
8use Illuminate\Console\Command;
10class MinutelyCommand extends Command
12 protected $signature = 'awards:minutely';
14 protected $description = 'Has any dateable been actioned in this minute?';
16 public function handle(): void
17 {
18 // this minute
19 $now = Carbon::now()->format('Y-m-d H:i:%');
21 // are there any dates changing NOW?
22 $minutely = Dates::query()
23 ->with(['dateable'])
24 ->where('awards_visible_from', 'LIKE', $now)
25 ->get();
27 if ($minutely->count()) {
28 $this->info('Found '.$minutely->count().' dateables');
29 RefreshWebsiteCacheJob::dispatch(
30 $minutely
31 ->map(fn (Dates $dates) => $dates->dateable->season_id)
32 ->unique()
33 ->toArray());
34 } else {
35 $this->info('No dateables to refresh this minute.');
36 }
37 }

Seriously easy.

The Statamic site

On to the Statamic site, I needed to figure out what pages to remove from the static cache.

The heavy-handed approach would be to wipe the entire cache: however that’s a bit aggressive, especially if there are other pages that are computationally expensive that don’t need to be cleared.

In this use case, the pages themselves are not actually Entries within the Statamic ecosystem, but Laravel routes that return a Statamic View and therefore statically cached. This means we can’t perform an invalidate call on the, well, Entry (?) because there is no Entry - it’s just a route. So instead, I hooked up to the Cacher, and invalidated the URLs that use that Season.

3namespace App\Http\Controllers;
5use Illuminate\Support\Str;
6use Statamic\Facades\Entry;
7use Statamic\StaticCaching\Cacher;
9class DateableController extends Controller
11 public function __invoke()
12 {
13 // only run if static caching is enabled
14 if (!config('statamic.static_caching.strategy', null)) {
15 return response()->json(['status' => '200']);
16 }
18 // get the seasons
19 $seasons = request()->get('seasons', []);
21 // get the Season entries that use this id
22 $entries = Entry::query()
23 ->where('collection', 'seasons')
24 ->whereIn('season', $seasons)
25 ->get()
26 ->map(fn(\Statamic\Entries\Entry $entry) => $entry->url);
28 // make the invalidator
29 $cache = app(Cacher::class);
31 // get the URLs that start with the season, and invalidate them
32 $cache->getUrls()
33 ->filter(fn(string $url) => Str::startsWith($url, $entries))
34 ->each(fn(string $url) => $cache->invalidateUrl($url));
36 // clear the static cache
37 return response()->json(['status' => '200']);
38 }

The cache can return a list of cached URLs, and then (because of the site’s URL structure) can filter the URLs that start with the specific season’s details - and then for each of those, clear the URL from the cache.

And boom, we now have a Laravel app telling Statamic at a specific point in time to clear selected routes from the static cache.

This is so cool!

Of course there’s more to it than just this: ensuring the call is private and all that - but for simplicity, that has been removed from examples here to illustrate the point: it’s possible for another app to help keep Statamic’s static cache fresh.

Even with automated tests, the first time this code ran, I was a tad nervous and curious to see it work in production for the first time. And the client of course had configured everything to go live at 11:59pm. But at 11:59pm, I was watching Statamic’s static cache folder, and saw the Category-only statically cached version get cleared - but not the surrounding unrelated cached content - and on visiting the route, seeing the new Category-and-Winners version be rendered and cached.

It absolutely works a treat, and shows once again just how flexible and powerful the Laravel ecosystem is - and of course through extension, Statamic too given it is built on Laravel.

While there are so many specifics to this application setup, hopefully this gives you ideas of how you could have a pair of a Laravel app and Statamic website working together harmoniously - including full static caching.

You may be interested in...