Creating a Hybrid Cache System for Statamic: Part Six

September 3, 2023 —John Koster

#Cache Reporting and Stats

In this section, we will add some simple reporting features to our cache and two more Artisan commands. Our cache reporting will provide the total disk size of our cache, the number of cached responses, and some information about our cache namespaces and labels. We will use a dedicated App\HybridCache\CacheStats class to hold all of our information and help prevent passing around more mysterious data arrays.

In app/HybridCache/CacheStats.php:

1<?php
2 
3namespace App\HybridCache;
4 
5class CacheStats
6{
7 public function __construct(
8 public int $cacheSize,
9 public int $labelCount,
10 public int $responseCount,
11 public array $labels)
12 {
13 }
14}

Our new CacheStats class utilizes PHP 8's constructor property promotion feature, which lets us reduce the boilerplate code we need to add. We will use the properties of our class to store various bits of information about the state of our cache:

  • cacheSize: The size of the cache on disk, in bytes.
  • labelCount: The number of unique cache namespaces and labels.
  • responseCount: The total number of cached responses.
  • labels: A list of unique cache labels and namespaces.

Our next step will be to create our class to generate the cache report for us.

In app/HybridCache/StatsProvider.php:

1<?php
2 
3namespace App\HybridCache;
4 
5use Illuminate\Support\Str;
6use RecursiveDirectoryIterator;
7use RecursiveIteratorIterator;
8use SplFileInfo;
9 
10class StatsProvider
11{
12 public function getStats(): CacheStats
13 {
14 $cachePath = storage_path('hybrid-cache');
15 $labelsPath = storage_path('hybrid-cache/labels');
16 $cacheSize = 0;
17 $labelCount = 0;
18 $responseCount = 0;
19 $labels = [];
20 
21 /** @var SplFileInfo $file */
22 foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($cachePath)) as $file) {
23 if ($file->isFile()) {
24 $fileSize = $file->getSize();
25 
26 if ($fileSize) {
27 $cacheSize += $fileSize;
28 }
29 
30 if (Str::startsWith($file->getPath(), $labelsPath)) {
31 $labelCount += 1;
32 
33 $labels[] = $file->getFilename();
34 } else {
35 $responseCount += 1;
36 }
37 }
38 }
39 
40 // Account for the global invalidation file.
41 $responseCount -= 1;
42 
43 return new CacheStats($cacheSize, $labelCount, $responseCount, $labels);
44 }
45}

The getStats implementation will recursively iterate all of our cache directories and keep a running total of the total file size and the number of cached responses and labels it finds along the way. Instead of writing a custom recursive function, I am taking advantage of PHP's RecursiveDirectoryIterator, which can scan through directories on the filesystem; when we combine that with the RecursiveIteratorIterator, we can iterate all nested file paths as if it were a single, flat array.

We can now use our new StatsProvider to implement two new Artisan commands. One will allow us to view all cache namespaces and labels within our cached responses, and the second will provide a simple overview of the state of the cache.

In app/HybridCache/Commands/CacheLabels.php:

1<?php
2 
3namespace App\HybridCache\Commands;
4 
5use App\HybridCache\StatsProvider;
6use Illuminate\Console\Command;
7use Illuminate\Support\Str;
8 
9class CacheLabels extends Command
10{
11 protected $signature = 'hybrid-cache:labels';
12 
13 public function __invoke(StatsProvider $cacheStats): void
14 {
15 $stats = $cacheStats->getStats();
16 
17 $this->table([
18 'Label',
19 'Clear Command',
20 ],
21 collect($stats->labels)->map(function ($label) {
22 $labelDisplay = 'php artisan hybrid-cache:invalidate-label ';
23 
24 if (Str::contains($label, '__')) {
25 $namespace = Str::before($label, '__');
26 $labelName = Str::after($label, '__');
27 $labelDisplay .= $namespace.' '.$labelName;
28 } else {
29 $labelDisplay .= $label;
30 }
31 
32 return [$label, $labelDisplay];
33 })
34 );
35 }
36}

In app/HybridCache/Commands/CacheReport.php:

1<?php
2 
3namespace App\HybridCache\Commands;
4 
5use App\HybridCache\StatsProvider;
6use Illuminate\Console\Command;
7use Statamic\Support\Str;
8 
9class CacheReport extends Command
10{
11 protected $signature = 'hybrid-cache:report';
12 
13 public function __invoke(StatsProvider $cacheStats): void
14 {
15 $stats = $cacheStats->getStats();
16 
17 $this->table([
18 'Cache Size',
19 'Label Count',
20 'Cached Pages',
21 ], [
22 [
23 Str::fileSizeForHumans($stats->cacheSize),
24 $stats->labelCount,
25 $stats->responseCount,
26 ],
27 ]);
28 }
29}

Now we need to make them available by updating our cache's service provider.

In app/HybridCache/Providers/HybridCacheServiceProvider.php:

1<?php
2 
3namespace App\HybridCache\Providers;
4 
5use App\HybridCache\Commands\CacheLabels;
6use App\HybridCache\Commands\CacheReport;
7use App\HybridCache\Commands\InvalidateAll;
8use App\HybridCache\Commands\InvalidateLabel;
9use App\HybridCache\Facades\HybridCache;
10use App\HybridCache\Manager;
11use Illuminate\Cookie\Middleware\EncryptCookies;
12use Illuminate\Support\ServiceProvider;
13use Illuminate\View\View;
14 
15class HybridCacheServiceProvider extends ServiceProvider
16{
17 // ...
18 
19 public function boot()
20 {
21 // ...
22 
23 if ($this->app->runningInConsole()) {
24 $this->commands(
25 InvalidateAll::class,
26 InvalidateLabel::class,
27 CacheLabels::class,
28 CacheReport::class,
29 );
30 }
31 }
32}

After clicking around the site for a while, running the hybrid-cache:labels command produced the following results for me:

1+----------------------+---------------------------------------------------------------+
2| Label | Clear Command |
3+----------------------+---------------------------------------------------------------+
4| collection | php artisan hybrid-cache:invalidate-label collection |
5| collection__articles | php artisan hybrid-cache:invalidate-label collection articles |
6| taxonomy | php artisan hybrid-cache:invalidate-label taxonomy |
7| taxonomy__topics | php artisan hybrid-cache:invalidate-label taxonomy topics |
8+----------------------+---------------------------------------------------------------+

Our command also lists the command that needs to run to invalidate all cached responses containing the listed label.

Similarly, running the hybrid-cache:report command displays a table with the data collected by our StatsProvider class:

1+------------+-------------+--------------+
2| Cache Size | Label Count | Cached Pages |
3+------------+-------------+--------------+
4| 140.00 KB | 4 | 11 |
5+------------+-------------+--------------+

#Invalidating Cached Laravel Route Responses

Our hybrid cache system is already quite capable but does present some challenges if we want to use routes or controllers to serve our content. The responses from these routes or controllers will be cached unless we exclude their URL patterns in our configuration. It would be nice if these responses invalidate themselves whenever the route or controller class files change.

To help explain the problem we will attempt to solve, let's create a simple controller and update our routes/web.php file.

In app/Http/Controllers/TestController.php:

1<?php
2 
3namespace App\Http\Controllers;
4 
5class TestController extends Controller
6{
7 public function index()
8 {
9 return 'Hello World!';
10 }
11}

In routes/web.php:

1<?php
2 
3use Illuminate\Support\Facades\Route;
4 
5Route::get('test', [\App\Http\Controllers\TestController::class, 'index']);
6 
7Route::get('test2', function () {
8 return 'Hello, world!';
9});

If we visit either of these URLs in our browser, our cache system will store the cached response. However, if we were to update the logic inside of our controller for the test route or the routes file for the test2 route, our cached content would not invalidate itself. To accomplish this, we will need to get somehow the file path to the the controller and the routes file.

To do this, we will need to utilize PHP's reflection features. Within our ResponsePreparedListener implementation, we can access the currently active route using Laravel's Route facade. Once we have the current route instance, we can check to see if it is a Closure-based route or if a controller backs it. With this information, we can use PHP's ReflectionFunction and ReflectionClass classes to retrieve the original file path.

In app/HybridCache/Listeners/ResponsePreparedListener.php:

1<?php
2 
3namespace App\HybridCache\Listeners;
4 
5use App\HybridCache\Facades\HybridCache;
6use Closure;
7use Illuminate\Routing\Events\ResponsePrepared;
8use Illuminate\Support\Facades\Auth;
9use Illuminate\Support\Facades\Route;
10use ReflectionClass;
11use ReflectionException;
12use ReflectionFunction;
13use Statamic\Facades\Entry;
14use Statamic\Facades\Term;
15 
16class ResponsePreparedListener
17{
18 public function handle(ResponsePrepared $event)
19 {
20 // ...
21 
22 // Handle routes.
23 $currentRoute = Route::current();
24 
25 if ($currentRoute) {
26 $uses = $currentRoute->getAction('uses');
27 
28 try {
29 if ($uses instanceof Closure) {
30 $reflection = new ReflectionFunction($uses);
31 
32 $paths[] = $reflection->getFileName();
33 
34 } elseif (is_string($uses) && $currentRoute->getControllerClass()) {
35 $reflection = new ReflectionClass($currentRoute->getControllerClass());
36 
37 $paths[] = $reflection->getFileName();
38 }
39 } catch (ReflectionException $e) {
40 return false;
41 }
42 }
43 
44 $paths = array_merge($paths, $responseDependencies['viewPaths']);
45 $paths = array_merge($paths, $responseDependencies['globalPaths']);
46 
47 // ...
48 
49 file_put_contents($cacheFileName, json_encode($cacheData));
50 }
51}

In the case of the test route, our cached content would look similar to the following example:

1{
2 "expires": null,
3 "content": "Hello World!",
4 "paths": {
5 "cache_dev\/storage\/hybrid-cache\/global-invalidation": 1693682789,
6 "cache_dev\/app\/Http\/Controllers\/TestController.php": 1693681390
7 },
8 "headers": {
9 "content-type": [
10 "text\/html; charset=UTF-8"
11 ],
12 "cache-control": [
13 "no-cache, private"
14 ]
15 }
16}

And our test2 route would produce a result similar to:

1{
2 "expires": null,
3 "content": "Hello, world!",
4 "paths": {
5 "cache_dev\/storage\/hybrid-cache\/global-invalidation": 1693682789,
6 "cache_dev\/routes\/web.php": 1693682434
7 },
8 "headers": {
9 "content-type": [
10 "text\/html; charset=UTF-8"
11 ],
12 "cache-control": [
13 "no-cache, private"
14 ]
15 }
16}

#Excluding Pages with CSRF Tokens

In this section, we will update our ResponseCreatedListener implementation to prevent caching pages containing Laravel's CSRF token. As stated during the introduction, this will help simplify the management of pages containing forms we don't want to exclude.

In app/HybridCache/Listeners/ResponsePreparedListener.php:

1<?php
2 
3namespace App\HybridCache\Listeners;
4 
5use App\HybridCache\Facades\HybridCache;
6use Closure;
7use Illuminate\Routing\Events\ResponsePrepared;
8use Illuminate\Support\Facades\Auth;
9use Illuminate\Support\Facades\Route;
10use ReflectionClass;
11use ReflectionException;
12use ReflectionFunction;
13use Statamic\Facades\Entry;
14use Statamic\Facades\Term;
15 
16class ResponsePreparedListener
17{
18 public function handle(ResponsePrepared $event)
19 {
20 // ...
21 
22 $content = $event->response->getContent();
23 
24 if (mb_strlen($content) == 0) {
25 return;
26 }
27 
28 if (str_contains($content, csrf_token())) {
29 return;
30 }
31 
32 $responseDependencies = HybridCache::getCacheData();
33 
34 $paths = [
35 storage_path('hybrid-cache/global-invalidation'),
36 ];
37 
38 // ...
39 }
40}

#Torchlight Syntax Highlighting and Cache Management

In this final section, we will look at integrating our custom cache system with other software packages. To do this, we will explore my favorite method of adding syntax highlighting to a website: Torchlight.

Torchlight is a service that accepts blocks of code and highlights them with the same engine used by Visual Studio Code, with support for many VS Code themes and a wide variety of other features.

While Torchlight is an excellent service, pages with numerous code blocks might not be fully highlighted after the initial request. Generally, Torchlight manages to highlight everything by the second or third request. However, when paired with our aggressive caching approach, there's a risk of caching pages that have incomplete syntax highlighting.

In my Statamic projects, I use Torchlight's CommonMark PHP client. However, the concepts we'll explore can apply generally to their other Composer packages. If you want to follow along, you may install their CommonMark client by running the following command from the root of your project:

1composer require torchlight/torchlight-commonmark

Whenever Torchlight runs into an issue that prevents it from highlighting a code block, it throws an exception unless it runs in a production environment. After some code diving, I was able to find that their client implementation has this logic wrapped in a protected method:

In torchlight-laravel/main/src/Client.php:

1<?php
2/**
3 * @author Aaron Francis <aaron@hammerstone.dev|https://twitter.com/aarondfrancis>
4 */
5 
6namespace Torchlight;
7 
8// ...
9 
10class Client
11{
12 // ...
13 
14 protected function throwUnlessProduction($exception)
15 {
16 throw_unless(Torchlight::environment() === 'production', $exception);
17 }
18 
19 // ...
20}

If we create our own implementation of this class, we can override the throwUnlessProduction method and call our cache's abandonCache method whenever it is called.

In app/TorchlightClient.php:

1<?php
2 
3namespace App;
4 
5use App\HybridCache\Facades\HybridCache;
6use Torchlight\Client;
7 
8class TorchlightClient extends Client
9{
10 protected function throwUnlessProduction($exception)
11 {
12 HybridCache::abandonCache();
13 
14 parent::throwUnlessProduction($exception);
15 }
16}

Our next challenge will be figuring out how to use our custom Torchlight client. After more source diving, we find that Torchlight uses its Manager class to manage client interactions. Internally, it will create a client instance, but luckily, it allows us to set our own client instance using its setClient method. We could call this method with another afterResolving callback, but Torchlight provides a nice facade wrapper we can use in our service provider's boot method.

In app/Providers/AppServiceProvider.php:

1<?php
2 
3namespace App\Providers;
4 
5use App\Data\Asset;
6use App\Data\Entry;
7use App\Data\Term;
8use App\Data\Variables;
9use App\TorchlightClient;
10use Illuminate\Support\ServiceProvider;
11use Statamic\Contracts\Assets\Asset as AssetContract;
12use Statamic\Contracts\Entries\Entry as EntryContract;
13use Statamic\Contracts\Globals\Variables as VariablesContract;
14use Statamic\Contracts\Taxonomies\Term as TermContract;
15use Statamic\Statamic;
16use Torchlight\Torchlight;
17 
18class AppServiceProvider extends ServiceProvider
19{
20 /**
21 * Register any application services.
22 */
23 public function register(): void
24 {
25 $this->app->bind(AssetContract::class, Asset::class);
26 $this->app->bind(EntryContract::class, Entry::class);
27 $this->app->bind(TermContract::class, Term::class);
28 $this->app->bind(VariablesContract::class, Variables::class);
29 }
30 
31 /**
32 * Bootstrap any application services.
33 */
34 public function boot(): void
35 {
36 // Statamic::vite('app', [
37 // 'resources/js/cp.js',
38 // 'resources/css/cp.css',
39 // ]);
40 
41 Torchlight::setClient(new TorchlightClient());
42 }
43}

After these changes, if Torchlight encounters an exception during its operation, our cache system's ability to save the response will be disabled. This step ensures that we don't store pages with incomplete or missing syntax highlighting, preserving the integrity and quality of our content display.

Get the PDF version on LeanPub Grab the example code on GitHub

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.