Content caching with Statamic

So this past week, I rewrote my website again (pause for audible gasp). I've had many different versions of my website over the years, each a reflection of my current technology obsession at the time.
From Hugo, to Next.js, to Astro, back to Next.js, then Nuxt, then Svelte, then back to Nuxt... you get the picture. I think I've spent more time rebuilding my own personalized blogging engine than actually writing meaningful content. That's boring though, and neither here nor there.
I've landed on the final iteration of it (for real this time, I swear) with Laravel and Statamic. I've been working a lot with Statamic lately building websites for some clients and have quickly placed it as my go to CMS. It's approachable, extremely flexible, and offers a good balance of customization and ease of use with the built in utilities it provides.
I took the past week to migrate my hacked up blogging engine that was essentially markdown files manually/programmatically parsed with CommonMark for PHP that heavily relied on shiki for code highlighting (by way of Spatie's shiki-php) and shoved in a SQLite database. It was nice being in full control of the pipeline (I even wrote about it!), but these days, I just want to focus on writing and content rather than building yet another blog engine (Y.A.B.E.) to scratch my own itch.
That's where Statamic comes in. With it, I centralize all of my content in flat files and focus on writing. I use a lot of code examples in all my writing, so naturally I wanted to bring over Shiki for highlighting. Luckily, Statamic makes it easy to hook into their markdown parsing process, providing a sensible set of defaults for parsing said markdown with the PHP League's CommonMark package. All that's needed is one additional bit of configuration in my AppServiceProvider.php
to highlight code with Shiki, again with the help of Statie's shiki-php:
final class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
self::configureMarkdown();
// Other stuff...
}
private function configureMarkdown(): void
{
Markdown::addExtensions(function () {
return [
new HighlightCodeExtension(theme: 'one-dark-pro'),
];
});
}
// Other stuff...
}
This works™️, but unfortunately Shiki is quite resource intensive on a browser page due to all the custom styling that goes into each section of highlighted code. Throw in fancy things like blurred or focused lines, and you can easily weigh down a page's load time pretty significantly.
An easy way to get around this is using Statamic's cache tag. For a blog page, my Antler's file (Statamic's custom DSL for markup) looks like this:
<div class="px-4 py-16 lg:px-8">
<div class="mx-auto max-w-3xl">
<h1 class="text-center text-4xl font-semibold">
{{ title }}
</h1>
<div class="flex flex-row items-center justify-center gap-x-4 py-4">
{{ topics limit="1" }}
<span class="badge badge-neutral">{{ title }}</span>
{{ /topics }}
<span class="text-xs">{{ date format="M j, Y" }}</span>
</div>
{{ image }}
<img
class="mx-auto h-full rounded-md py-8"
src="{{ url }}"
alt="{{ alt }}"
width="400"
height="400"
/>
{{ /image }}
<div class="prose mt-4 max-w-full">
{{ cache :key="slug" }}
{{ content }}
{{ /cache }}
</div>
</div>
</div>
The :key
here (no pun intended) for the cached content being the slug of the blog page. Now since I'm not providing a TTL for the cached content, the content of my blog posts will be cached forever, until the end of time, long after all matter in the known universe has decayed to iron-56.
To get around this, we can programmatically bust the cache. Now, the great benefit of Statamic being a CMS is that I can update content as I please without the need to physically deploy any new code.
By default, Statamic uses flat files to store content alongside some metadata about page the content is associated to. Even better, Statamic will also fire events anytime the content is updated. Statamic will store cached content in your cache store of choice with keys along the lines of statamic_cache_{{key}}
that you specify, which in my case, are the slugs to the blog itself. Laravel makes this way too easy to then hook into the content saved event and bust the cached value like so:
final class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Other stuff...
self::configureEvents();
}
private function configureEvents(): void
{
Event::listen(function (EntrySaved $event) {
/** @var array{slug: string} $entry */
$entry = $event->entry;
if (Cache::has($entry['slug'])) {
Cache::forget($entry['slug']);
}
});
}
// Other stuff...
}
I register this event listener in my AppServiceProvider.php
primarily because I'm incredibly lazy and it's the only event I'm concerned about on my website. Now, anytime I update my content like fixing a typo on an old post, I'll bust that particular cache entry and re-cache the updated value when the page is first visited. Pretty sweet!
There's one other issue. Remember how I said Shiki is resource intensive? Well... that's because it is. If you have a lot of code being highlighted, it'll take awhile to load your page - not a great user experience. For instance, here's what the above highlighted code looks like once Shiki does its magic:
<pre class="shiki" style="background-color: #282c34">
<code>
<span class="line">
<span style="color:#C678DD">final</span>
<span style="color:#ABB2BF"></span>
<span style="color:#C678DD">class</span>
<span style="color:#ABB2BF"></span>
<span style="color:#E5C07B">AppServiceProvider</span>
<span style="color:#ABB2BF"></span>
<span style="color:#C678DD">extends</span>
<span style="color:#ABB2BF"></span>
<span style="color:#E5C07B">ServiceProvider</span>
</span>
<span class="line">
<span style="color:#ABB2BF">{</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#C678DD">public</span>
<span style="color:#ABB2BF"></span>
<span style="color:#C678DD">function</span>
<span style="color:#ABB2BF"></span>
<span style="color:#61AFEF">boot</span>
<span style="color:#ABB2BF">(): </span>
<span style="color:#E5C07B">void</span>
</span>
<span class="line">
<span style="color:#ABB2BF"> {</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#7F848E;font-style: italic">// Other stuff...</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#E5C07B">self</span>
<span style="color:#ABB2BF">::</span>
<span style="color:#61AFEF">configureEvents</span>
<span style="color:#ABB2BF">();</span>
</span>
<span class="line">
<span style="color:#ABB2BF"> }</span>
</span>
<span class="line"></span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#C678DD">private</span>
<span style="color:#ABB2BF"></span>
<span style="color:#C678DD">function</span>
<span style="color:#ABB2BF"></span>
<span style="color:#61AFEF">configureEvents</span>
<span style="color:#ABB2BF">(): </span>
<span style="color:#E5C07B">void</span>
</span>
<span class="line">
<span style="color:#ABB2BF"> {</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#E5C07B">Event</span>
<span style="color:#ABB2BF">::</span>
<span style="color:#61AFEF">listen</span>
<span style="color:#ABB2BF">(</span>
<span style="color:#C678DD">function</span>
<span style="color:#ABB2BF"> (</span>
<span style="color:#E5C07B">EntrySaved</span>
<span style="color:#ABB2BF"></span>
<span style="color:#E06C75">$event</span>
<span style="color:#ABB2BF">) {</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#7F848E;font-style: italic">/** </span>
<span style="color:#C678DD;font-style: italic">@var</span>
<span style="color:#7F848E;font-style: italic"></span>
<span style="color:#E5C07B;font-style: italic">array{slug:</span>
<span style="color:#7F848E;font-style: italic"> string} $entry */</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#E06C75">$entry</span>
<span style="color:#ABB2BF"></span>
<span style="color:#56B6C2">=</span>
<span style="color:#ABB2BF"></span>
<span style="color:#E06C75">$event</span>
<span style="color:#ABB2BF">-></span>
<span style="color:#E06C75">entry</span>
<span style="color:#ABB2BF">;</span>
</span>
<span class="line"></span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#C678DD">if</span>
<span style="color:#ABB2BF"> (</span>
<span style="color:#E5C07B">Cache</span>
<span style="color:#ABB2BF">::</span>
<span style="color:#61AFEF">has</span>
<span style="color:#ABB2BF">(</span>
<span style="color:#E06C75">$entry</span>
<span style="color:#ABB2BF">[</span>
<span style="color:#98C379">'slug'</span>
<span style="color:#ABB2BF">])) {</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#E5C07B">Cache</span>
<span style="color:#ABB2BF">::</span>
<span style="color:#61AFEF">forget</span>
<span style="color:#ABB2BF">(</span>
<span style="color:#E06C75">$entry</span>
<span style="color:#ABB2BF">[</span>
<span style="color:#98C379">'slug'</span>
<span style="color:#ABB2BF">]);</span>
</span>
<span class="line">
<span style="color:#ABB2BF"> }</span>
</span>
<span class="line">
<span style="color:#ABB2BF"> });</span>
</span>
<span class="line">
<span style="color:#ABB2BF"> }</span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
</span>
<span class="line">
<span style="color:#ABB2BF"></span>
<span style="color:#7F848E;font-style: italic">// Other stuff...</span>
</span>
<span class="line">
<span style="color:#ABB2BF">}</span>
</span>
<span class="line"></span>
</code>
</pre>
Not to mention the fact I'm not tree shaking themes or languages that I'm not using either. I probably could... but I'll save that for a rainy day.
So that's great that we cache the content, but we need someone to trigger the caching in the first place. And that sucks. I don't know about you, but I'm not waiting 20 seconds for a blog post to load so I can read it. So how do we get around that?
Commands to rescue!
With Statamic, while templating with Antlers offers a seamless experience for pulling content out of storage onto the page, it also gives us the ability to query content in an Eloquent-like fashion. Using this approach, I'm able to spin up a simple command to run on deployment whenever I push new content:
final class WarmBlogCacheCommand extends Command
{
/**
* @var string
*/
protected $signature = 'app:warm-blog-cache';
/**
* @var string|null
*/
protected $description = 'Warms the Statamic cache with shiki content for the blog.';
public function handle(): void
{
/** @var EntryCollection $entries */
$entries = Entry::query() // @phpstan-ignore-line
->where('collection', 'blogs')
->where('published', true)
->get(['content']);
$this->info("Warming cache for {$entries->count()} blog entries...");
/** @var \Statamic\Entries\Entry $entry */
foreach ($entries as $entry) {
/** @var string $key */
$key = $entry->slug();
$this->info("Caching: $key");
/** @var string $content */
$content = $entry->get('content');
Cache::rememberForever($key, fn () => Markdown::parser('default')->parse($content));
$this->info("Cached: $key");
}
/** @var \Statamic\Entries\Entry $entry */
foreach ($entries as $entry) {
/** @var string $key */
$key = $entry->slug();
if (! Cache::has($key)) {
$this->error("Cache missing for: $key");
}
}
$this->info('Blog cache warming complete!');
}
}
Nothing fancy in the above. I pull all of the entries in my blog collection, run them through the markdown parser that's using Spatie's Shiki extension for code highlighting with the use of Markdown::parser('default')
, and voilà - no one has to pay the price for waiting for content to be parsed!
I've gotta fire this command somewhere though. My site lives on a Hetzner VPS that's provisioned by Forge (as the good Lord intended). To re-cache all the entries, I'm able to plop this command right into my deploy script:
cd /home/forge/joeymckenzie.tech
git pull origin $FORGE_SITE_BRANCH
$FORGE_COMPOSER install --no-dev --no-interaction --prefer-dist --optimize-autoloader
# Prevent concurrent php-fpm reloads
touch /tmp/fpmlock 2>/dev/null || true
( flock -w 10 9 || exit 1
echo 'Reloading PHP FPM...'; sudo -S service $FORGE_PHP_FPM reload ) 9</tmp/fpmlock
if [ -f artisan ]; then
npm ci && npm run build
$FORGE_PHP please cache:clear
$FORGE_PHP please app:warm-blog-cache
$FORGE_PHP artisan migrate --force
fi
And when I deploy, I can see the caching in action:
// Other deployment stuff...
Warming cache for 28 blog entries...
Caching: content-caching-with-statamic
Cached: content-caching-with-statamic
Caching: dynamic-error-assertions-with-phpstan
Cached: dynamic-error-assertions-with-phpstan
// Other cache entries...
Blog cache warming complete!
INFO Nothing to migrate.
=> Deployment Complete!
Unfortunately, someone still has to pay the price when I bust the cached entries when I'm editing blog content in production, though I try to make sure that's always myself. The beauty of Statamic storing content in flat files is that my locally developed changes are always in sync with what's deployed. So a quick fix here or there, deploy, re-cache the content, and boom. All in a day's work.
Well, that's it for now. And if you haven't tried Statamic yet, this is your sign.
Until next time, friends!