Creating a Hybrid Cache System for Statamic: Part Four

September 3, 2023 —John Koster

#Setting Cache Expiration Times

In this section, we will work to implement the ability to set custom cache expiration durations from within our templates. The logic will be straightforward: We will provide utility methods, create an Antlers tag to specify a custom expiration, and store that alongside the cached response contents. When serving the cached content, we will compare any custom expiration times against the current time.

To get started, we will update our cache manager instance.

In app/HybridCache/Manager.php:

1<?php
2 
3namespace App\HybridCache;
4 
5class Manager
6{
7 // ...
8 
9 protected mixed $expiration = null;
10 
11 public function __construct()
12 {
13 self::$instance = $this;
14 
15 $configurationPath = realpath(__DIR__.'/../../config/hybrid-cache.php');
16 
17 if ($configurationPath) {
18 $this->configuration = require $configurationPath;
19 }
20 }
21 
22 public function setExpiration(mixed $expiration): void
23 {
24 if ($expiration < time()) {
25 $this->canCache = false;
26 
27 return;
28 }
29 
30 if ($this->expiration != null) {
31 if ($expiration < $this->expiration) {
32 $this->expiration = $expiration;
33 }
34 
35 return;
36 }
37 
38 $this->expiration = $expiration;
39 }
40 
41 public function getExpiration(): mixed
42 {
43 return $this->expiration;
44 }
45 
46 // ...
47}

In app/HybridCache/Facades/HybridCache.php:

1<?php
2 
3namespace App\HybridCache\Facades;
4 
5use App\HybridCache\Manager;
6use Illuminate\Support\Facades\Facade;
7 
8/**
9 * @method static bool canHandle()
10 * @method static void sendCachedResponse()
11 * @method static string|null getCacheFileName()
12 * @method static void registerViewPath(string $path)
13 * @method static void registerEntryId(string $id)
14 * @method static void registerAssetPath(string $path)
15 * @method static void registerTermId(string $id)
16 * @method static void registerGlobalPath(string $path)
17 * @method static array getCacheData()
18 * @method static bool canCache()
19 * @method static void abandonCache()
20 * @method static array getCacheableStatusCodes()
21 * @method static bool getIgnoreQueryString()
22 * @method static bool isCacheBypassed()
23 * @method static void setExpiration(mixed $expiration)
24 * @method static mixed getExpiration()
25 *
26 * @see \App\HybridCache\Facades\HybridCache
27 */
28class HybridCache extends Facade
29{
30 protected static function getFacadeAccessor()
31 {
32 return Manager::class;
33 }
34}

We've introduced two methods: setExpiration and getExpiration. The setExpiration method lets us set a custom date/time for cache expiration while getExpiration fetches any custom value we've already established.

In the setExpiration implementation, a few aspects stand out:

  1. On line 24, there's a condition checking if the given expiration time is earlier than the current time. If so, we turn off the cache internally. This design choice lets template authors use a value of -1 (or any time before the present) to turn off caching.
  2. Between lines 30 and 35, we only modify the saved expiration time if the incoming value is earlier than the already saved value. This setup means template authors can specify an expiration time on an events listing page using the Antlers tag we'll develop later. They can do this without crafting custom logic to decide which event time on the page should dictate the cache's expiration date.

With our helpers out of the way, we can now update our ResponsePreparedListener to save the cache expiration with the response:

In app/HybridCache/Listeners/ResponsePreparedListener.php:

1<?php
2 
3namespace App\HybridCache\Listeners;
4 
5use App\HybridCache\Facades\HybridCache;
6use Illuminate\Routing\Events\ResponsePrepared;
7use Illuminate\Support\Facades\Auth;
8use Statamic\Facades\Entry;
9use Statamic\Facades\Term;
10 
11class ResponsePreparedListener
12{
13 public function handle(ResponsePrepared $event)
14 {
15 // ...
16 
17 unset($headers['set-cookie']);
18 unset($headers['date']);
19 
20 $cacheData = [
21 'expires' => HybridCache::getExpiration(),
22 'content' => $content,
23 'paths' => $timestamps,
24 'headers' => $headers,
25 ];
26 
27 file_put_contents($cacheFileName, json_encode($cacheData));
28 }
29}

The final updates will be within the cache manager itself. If the expires value is available with the cached content, we will check if the cached response is still valid before continuing:

In app/HybridCache/Manager.php:

1<?php
2 
3namespace App\HybridCache;
4 
5class Manager
6{
7 // ...
8 
9 public function sendCachedResponse(): void
10 {
11 if ($this->cacheFileName && ! file_exists($this->cacheFileName)) {
12 return;
13 }
14 
15 $cacheContents = json_decode(file_get_contents($this->cacheFileName), true);
16 
17 if (! $cacheContents) {
18 return;
19 }
20 
21 if (! array_key_exists('headers', $cacheContents)) {
22 return;
23 }
24 
25 if (! isset($cacheContents['paths'])) {
26 return;
27 }
28 
29 if (! isset($cacheContents['content'])) {
30 return;
31 }
32 
33 if (array_key_exists('expires', $cacheContents)) {
34 $expiration = $cacheContents['expires'];
35 
36 if ($expiration != null && time() > $expiration) {
37 @unlink($this->cacheFileName);
38 
39 return;
40 }
41 }
42 
43 foreach ($cacheContents['paths'] as $path => $cachedMTime) {
44 if (! file_exists($path) || filemtime($path) > $cachedMTime) {
45 @unlink($this->cacheFileName);
46 
47 return;
48 }
49 }
50 
51 $headers = $cacheContents['headers'];
52 $headers['date'] = [gmdate('D, d M Y G:i:s ').'GMT'];
53 
54 foreach ($headers as $headerName => $values) {
55 if (count($values) != 1) {
56 continue;
57 }
58 
59 $value = $values[0];
60 
61 header($headerName.': '.$value);
62 }
63 
64 echo $cacheContents['content'];
65 exit;
66 }
67}

Our final task in this section will be to update our custom Antlers tag:

In app/Tags/HybridCache.php:

1<?php
2 
3namespace App\Tags;
4 
5use App\HybridCache\Facades\HybridCache as Cache;
6use Carbon\Carbon;
7use Statamic\Tags\Tags;
8 
9class HybridCache extends Tags
10{
11 protected static $handle = 'hybrid_cache';
12 
13 // ...
14 
15 public function expire()
16 {
17 $ttl = $this->params->get(['ttl', 'in'], null);
18 
19 if ($ttl != null) {
20 Cache::setExpiration(Carbon::now()->addSeconds($ttl)->timestamp);
21 
22 return;
23 }
24 
25 $on = $this->params->get(['on', 'at'], null);
26 
27 if ($on != null) {
28 Cache::setExpiration(Carbon::parse($on)->timestamp);
29 }
30 }
31}

The hybrid_cache:expire tag offers template authors two usage options:

  1. They can define a ttl or in parameter, indicating how many seconds the cache response should remain valid. When used, the Carbon library adds the specified seconds to the current time, and we then set the resulting timestamp in the cache manager.
  2. Alternatively, authors can use the on or at parameter to specify the exact date when the cache should expire.

Used within a template, the ttl or in parameters may look like this:

1{{# The cache should expire in 3600 seconds. #}}
2{{ hybrid_cache:expire in="3600" }}

Whereas, we could use the on or at parameters within an event listings loop to ensure the current page expires on the earliest available date and time:

1 
2{{ events }}
3 <dl>
4 <dt>Event</dt>
5 <dd>{{ title }}</dd>
6 <dt>Location</dt>
7 <dd>{{ location }}</dd>
8 <dt>Time</dt>
9 <dd>
10 {{ event_time | iso_format('LLL') }}
11 {{ hybrid_cache:expire :on="event_time" }}
12 </dd>
13 </dl>
14{{ /events }}

#Invalidating All Cached Responses

In this section, we will work to update our cache system to invalidate all cached responses at once, which can be a helpful feature in certain situations. For example, if we had a bug in generating the content, we might want to invalidate all the cached content so users get the correct content the next time they visit. When thinking about developing this type of feature, the first approach we might come up with would be to iterate and delete all of the cached files. However, we can be more clever than that and invalidate our cache files without deleting them.

We will take advantage of the fact that our cached content will invalidate itself based on the timestamps of any file within the cached paths array by creating a single file that we will add to all cached responses. When we want to invalidate all cached content, we can use PHP's touch function to update the timestamp on the file, causing all cached responses to become invalid.

Our first step will be to update our HybridCacheServiceProvider to create our global invalidation path if it doesn't exist.

In app/HybridCache/Providers/HybridCacheServiceProvider.php:

1<?php
2 
3namespace App\HybridCache\Providers;
4 
5use App\HybridCache\Facades\HybridCache;
6use App\HybridCache\Manager;
7use Illuminate\Cookie\Middleware\EncryptCookies;
8use Illuminate\Support\ServiceProvider;
9use Illuminate\View\View;
10 
11class HybridCacheServiceProvider extends ServiceProvider
12{
13 // ...
14 
15 public function boot()
16 {
17 $cacheStoragePath = storage_path('hybrid-cache');
18 $globalInvalidationPath = $cacheStoragePath.'/global-invalidation';
19 
20 if (! file_exists($cacheStoragePath)) {
21 mkdir(storage_path('hybrid-cache'), 0755, true);
22 }
23 
24 if (! file_exists($globalInvalidationPath)) {
25 touch($globalInvalidationPath);
26 }
27 
28 view()->composer('*', function (View $view) {
29 HybridCache::registerViewPath($view->getPath());
30 });
31 }
32}

After this, we can update our ResponsePreparedListener and add it to the paths value for all cached content.

In app/HybridCache/Listeners/ResponsePreparedListener.php:

1<?php
2 
3namespace App\HybridCache\Listeners;
4 
5use App\HybridCache\Facades\HybridCache;
6use Illuminate\Routing\Events\ResponsePrepared;
7use Illuminate\Support\Facades\Auth;
8use Statamic\Facades\Entry;
9use Statamic\Facades\Term;
10 
11class ResponsePreparedListener
12{
13 public function handle(ResponsePrepared $event)
14 {
15 // ...
16 
17 $responseDependencies = HybridCache::getCacheData();
18 
19 $paths = [];
20 $paths = [
21 storage_path('hybrid-cache/global-invalidation'),
22 ];
23 
24 $paths = array_merge($paths, $responseDependencies['viewPaths']);
25 $paths = array_merge($paths, $responseDependencies['globalPaths']);
26 
27 // ...
28 }
29}

If we manually deleted all of our cached content, regenerated the cached contents, and then made any changes to our storage/hybrid-cache/global-invalidation file, we should see the response times increase as the cache is invalidated. Manually updating or removing this file technically works, but it takes some work, and we can improve on this experience. To help address this, we will create our first Artisan command.

Before implementing our Artisan command, we'll need a new helper method to update our global-invalidation file within our cache manager.

In app/HybridCache/Manager.php:

1<?php
2 
3namespace App\HybridCache;
4 
5class Manager
6{
7 // ...
8 
9 public function __construct()
10 {
11 self::$instance = $this;
12 
13 $configurationPath = realpath(__DIR__.'/../../config/hybrid-cache.php');
14 
15 if ($configurationPath) {
16 $this->configuration = require $configurationPath;
17 }
18 }
19 
20 public function invalidateAll(): void
21 {
22 touch(storage_path('hybrid-cache/global-invalidation'));
23 }
24 
25 // ...
26}

In app/HybridCache/Facades/HybridCache.php:

1<?php
2 
3namespace App\HybridCache\Facades;
4 
5use App\HybridCache\Manager;
6use Illuminate\Support\Facades\Facade;
7 
8/**
9 * @method static bool canHandle()
10 * @method static void sendCachedResponse()
11 * @method static string|null getCacheFileName()
12 * @method static void registerViewPath(string $path)
13 * @method static void registerEntryId(string $id)
14 * @method static void registerAssetPath(string $path)
15 * @method static void registerTermId(string $id)
16 * @method static void registerGlobalPath(string $path)
17 * @method static array getCacheData()
18 * @method static bool canCache()
19 * @method static void abandonCache()
20 * @method static array getCacheableStatusCodes()
21 * @method static bool getIgnoreQueryString()
22 * @method static bool isCacheBypassed()
23 * @method static void setExpiration(mixed $expiration)
24 * @method static mixed getExpiration()
25 * @method static void invalidateAll()
26 *
27 * @see \App\HybridCache\Facades\HybridCache
28 */
29class HybridCache extends Facade
30{
31 protected static function getFacadeAccessor()
32 {
33 return Manager::class;
34 }
35}

Armed with our new helper, we can create a simple Artisan command implementation:

In app/HybridCache/Commands/InvalidateAll.php:

1<?php
2 
3namespace App\HybridCache\Commands;
4 
5use App\HybridCache\Facades\HybridCache;
6use Illuminate\Console\Command;
7 
8class InvalidateAll extends Command
9{
10 protected $signature = 'hybrid-cache:invalidate-all';
11 
12 public function __invoke(): void
13 {
14 $this->info('Invalidating all cache data...');
15 HybridCache::invalidateAll();
16 $this->info('Invalidating all cache data... done!');
17 }
18}

To make our command available to us when using the Artisan command line tool, we will now update our cache's service provider:

In app/HybridCache/Providers/HybridCacheServiceProvider.php:

1<?php
2 
3namespace App\HybridCache\Providers;
4 
5use App\HybridCache\Commands\InvalidateAll;
6use App\HybridCache\Facades\HybridCache;
7use App\HybridCache\Manager;
8use Illuminate\Cookie\Middleware\EncryptCookies;
9use Illuminate\Support\ServiceProvider;
10use Illuminate\View\View;
11 
12class HybridCacheServiceProvider extends ServiceProvider
13{
14 // ...
15 
16 public function boot()
17 {
18 $cacheStoragePath = storage_path('hybrid-cache');
19 $globalInvalidationPath = $cacheStoragePath.'/global-invalidation';
20 
21 if (! file_exists($cacheStoragePath)) {
22 mkdir(storage_path('hybrid-cache'), 0755, true);
23 }
24 
25 if (! file_exists($globalInvalidationPath)) {
26 touch($globalInvalidationPath);
27 }
28 
29 view()->composer('*', function (View $view) {
30 HybridCache::registerViewPath($view->getPath());
31 });
32 
33 if ($this->app->runningInConsole()) {
34 $this->commands(
35 InvalidateAll::class,
36 );
37 }
38 }
39}

After all of these changes, if we were to run the following command at the root of our project:

1php artisan hybrid-cache:invalidate-all

We should see output similar to the following:

1Invalidating all cache data...
2Invalidating all cache data... done!

Whenever we run our hybrid-cache:invalidate-all command, all of our cached responses should invalidate and regenerate the next time they are requested.

Get the PDF version on LeanPub Grab the example code on GitHub Proceed to Creating a Hybrid Cache System for Statamic: Part Five

Some absolutely amazing
people

The following amazing people help support this site and my open source projects ♥️
If you're interesting in supporting my work and want to show up on this list, check out my GitHub Sponsors Profile.