Creating a Hybrid Cache System for Statamic: Part Three

September 3, 2023 —John Koster

#Managing Cached Response Headers

To take a break from the more involved experimentation and code changes, we will work on improving how our cache system manages headers. Our cache doesn't do anything explicit with the headers, including the Date header. While we could get away with doing nothing with the headers on most sites, it will help put that final bit of polish on our implementation and provide us with a way to exclude some headers from the cached response.

The first step will be to adjust our response prepared listener to store the headers and the cached 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 Statamic\Facades\Entry;
8use Statamic\Facades\Term;
9 
10class ResponsePreparedListener
11{
12 public function handle(ResponsePrepared $event)
13 {
14 // ...
15 
16 $headers = $event->response->headers->all();
17 
18 unset($headers['set-cookie']);
19 unset($headers['date']);
20 
21 $cacheData = [
22 'content' => $content,
23 'paths' => $timestamps,
24 'headers' => $headers,
25 ];
26 
27 file_put_contents($cacheFileName, json_encode($cacheData));
28 }
29}

Doing this is relatively simple, as we can retrieve all the response headers within our listener. After we have the headers, we remove some specific headers; in our example, we remove the set-cookie and date headers.

Now that our cache manager is storing the headers with the cached response, we need to update our manager to send them when serving cached content.

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 foreach ($cacheContents['paths'] as $path => $cachedMTime) {
34 if (! file_exists($path) || filemtime($path) > $cachedMTime) {
35 @unlink($this->cacheFileName);
36 
37 return;
38 }
39 }
40 
41 $headers = $cacheContents['headers'];
42 $headers['date'] = [gmdate('D, d M Y G:i:s ').'GMT'];
43 
44 foreach ($headers as $headerName => $values) {
45 if (count($values) != 1) {
46 continue;
47 }
48 
49 $value = $values[0];
50 
51 header($headerName.': '.$value);
52 }
53 
54 echo $cacheContents['content'];
55 exit;
56 }
57}

Out of all the changes, how we handle the date header is the most interesting. We are utilizing PHP's gmdate function to construct a value matching the string value that Laravel would typically send. Remember, our sendCachedResponse method will only be called before Laravel and Statamic are available, so we must re-implement some features using native PHP functionality. We use PHP's header function to set our response headers for the same reason before echoing the content.

#Making Our Cache Configurable and Excluding Items from the Cache

In this section, we will work to make our cache system configurable and exclude requests from the cache. To get started, let us create the following file at config/hybrid-cache.php and discuss the types of behaviors we will make dynamic.

In config/hybrid-cache.php:

1<?php
2 
3return [
4 'ignore_uri_patterns' => [
5 '/!/.*?',
6 '/cp/.*?',
7 '/cp',
8 '/api/.*?',
9 '/contact',
10 ],
11 'cache_response_codes' => [
12 200,
13 301,
14 302,
15 303,
16 307,
17 308,
18 ],
19 'ignore_query_strings' => true,
20];

The ignore_uri_patterns array will allow us to provide a list of regular expressions. We will not cache the response if these expressions match the current request. The provided list includes examples of excluding the default control panel route, common API endpoint prefixes, and a contact page. The /!/.*? pattern is interesting and obscure. Internally, Statamic and some addons will create routes that begin with /!/ to handle POST requests, such as form submissions. Before we added these changes, if we had visited Statamic's Control Panel, we would have experienced inconsistent results, cached responses, or page expiration errors.

The final two configuration options are cache_response_codes and ignore_query_strings, which list which HTTP response codes we are allowed to cache and whether or not we should ignore query string requests, respectively.

We are adding our configuration management to our existing cache manager class. Since we need to reference some of these configuration items before Laravel becomes available, we can't use its configuration helpers. Fortunately, we have only a few configuration options, so manually handling them won't be too challenging.

In app/HybridCache/Manager.php:

1<?php
2 
3namespace App\HybridCache;
4 
5class Manager
6{
7 // ...
8 
9 protected array $configuration = [];
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 getCacheableStatusCodes(): array
23 {
24 if (array_key_exists('cache_response_codes', $this->configuration)) {
25 return $this->configuration['cache_response_codes'];
26 }
27 
28 return [
29 200,
30 301,
31 302,
32 303,
33 307,
34 308,
35 ];
36 }
37 
38 public function getIgnoreQueryStrings(): bool
39 {
40 if (array_key_exists('ignore_query_strings', $this->configuration)) {
41 return $this->configuration['ignore_query_strings'];
42 }
43 
44 return true;
45 }
46 
47 // ...
48}

And the corresponding changes to our facade.

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 *
23 * @see \App\HybridCache\Facades\HybridCache
24 */
25class HybridCache extends Facade
26{
27 protected static function getFacadeAccessor()
28 {
29 return Manager::class;
30 }
31}

Within our Manager's constructor, we use PHP's realpath function to get the full path to the configuration file we want to load. The realpath function will return a falsey value if the file doesn't exist or if there is some other issue with the file, which helps to simplify some of our logic.

While working within our cache manager, we can take care of a few quick refactors using our configuration values.

In app/HybridCache/Manager.php:

1<?php
2 
3namespace App\HybridCache;
4 
5class Manager
6{
7 // ...
8 
9 public function canHandle(): bool
10 {
11 if ($this->getIgnoreQueryStrings()) {
12 if (array_key_exists('QUERY_STRING', $_SERVER)) {
13 return false;
14 }
15 }
16 
17 // Ignore all request types except GET.
18 if ($_SERVER['REQUEST_METHOD'] != 'GET') {
19 return false;
20 }
21 
22 if (! empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) == 'xmlhttprequest') {
23 return false;
24 }
25 
26 if (array_key_exists('ignore_uri_patterns', $this->configuration)) {
27 foreach ($this->configuration['ignore_uri_patterns'] as $pattern) {
28 $pattern = str_replace('/', '\/', $pattern);
29 $pattern = '/^'.$pattern.'$/';
30 
31 if (preg_match($pattern, $_SERVER['REQUEST_URI'])) {
32 return false;
33 }
34 }
35 }
36 
37 $cacheDirectory = realpath(__DIR__.'/../../storage/hybrid-cache');
38 
39 if (! $cacheDirectory) {
40 return false;
41 }
42 
43 $requestUri = mb_strtolower($_SERVER['REQUEST_URI']);
44 
45 $this->cacheFileName = $cacheDirectory.'/'.sha1($requestUri).'.json';
46 
47 return file_exists($this->cacheFileName);
48 }
49 
50 // ...
51}

The check to see if we should ignore the current request based on the presence of a query string value happens between lines 11 and 15.

Between lines 26 and 35, we perform our pattern-based request filtering. We iterate each configured pattern and check it against the current request's URI using PHP's preg_match function.

The only addition not covered by a configuration value appears between lines 22 and 24. This addition will cause the request not to be cached if it is an AJAX request. The HTTP_X_REQUESTED_WITH header is not a standard HTTP header but is added by many JavaScript libraries when making an AJAX request.

The next changes will occur within our existing ResponsePreparedListener implementation.

In app/HybridCache/Listeners/ResponseCreatedListener.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 if (request()->ajax()) {
16 return;
17 }
18 
19 if (! HybridCache::canCache()) {
20 return;
21 }
22 
23 // Don't cache responses for authenticated users.
24 if (Auth::check()) {
25 return;
26 }
27 
28 if (! in_array($event->response->getStatusCode(), HybridCache::getCacheableStatusCodes())) {
29 return;
30 }
31 
32 $cacheFileName = HybridCache::getCacheFileName();
33 
34 // ...
35 }
36}

Our first addition between lines 15 and 17 will also help to prevent AJAX requests from being cached. Between lines 24 and 26, the new check ensures the system doesn't cache responses if we are logged in. For projects that need some caching, even for authenticated users, Statamic's cache tag offers a more suitable option. Between lines 28 and 30, another check confirms that the system caches only the responses with appropriate status codes, preventing the caching of "404 Not Found" or similar responses.

#Bypassing the Cache

Currently, our caching system doesn't cache pages viewed by logged-in users. However, if a page was cached before a user logs in, that cached page is served to all logged-in users. While this might work for some websites, it poses challenges when content needs to change depending on user sessions or when site administrators want to see an uncached version of the site. We need a way to bypass the cache completely.

Because our cache doesn't rely on purely static files, it still evaluates PHP for each request, making it simpler to detect logged-in users and turn off the cache than a strictly static HTML caching system. Yet, since we can't use Laravel or Statamic for authentication checks, we need to think outside the box to tackle this issue.

Thinking through the issue, we need to create some indicator within Laravel or Statamic that we can check against within our cache system. Additionally, our indicator cannot be some global system, as that would make handling multiple users an absolute nightmare. Due to our requirements, cookies will be beneficial.

We also need a way to detect when we log out of the Statamic site to implement our cache bypass. Luckily, Laravel provides several different events we can listen to to help do this:

  • Illuminate\Auth\Events\Login: Laravel fires this event when it successfully creates a new user session.
  • Illuminate\Auth\Events\Logout: Laravel fires this event when it destroys an active user session.

The general plan is to create a cookie inside a Login event listener and remove or update the cookie whenever Laravel fires the Logout event.

In app/HybridCache/Listeners/LoggedOutListener.php:

1<?php
2 
3namespace App\HybridCache\Listeners;
4 
5class LoggedOutListener
6{
7 public function handle($event)
8 {
9 cookie()->queue(cookie()->forever('X-Hybrid-Cache', 'false'));
10 }
11}

In app/HybridCache/Listeners/LoginSuccessListener.php:

1<?php
2 
3namespace App\HybridCache\Listeners;
4 
5class LoginSuccessListener
6{
7 public function handle($event)
8 {
9 cookie()->queue(cookie()->forever('X-Hybrid-Cache', 'true'));
10 }
11}

Now, we need to update our cache's event service provider:

In app/HybridCache/Providers/HybridCacheEventServiceProvider.php:

1<?php
2 
3namespace App\HybridCache\Providers;
4 
5use App\HybridCache\Listeners\LoggedOutListener;
6use App\HybridCache\Listeners\LoginSuccessListener;
7use App\HybridCache\Listeners\ResponsePreparedListener;
8use Illuminate\Auth\Events\Login;
9use Illuminate\Auth\Events\Logout;
10use Illuminate\Foundation\Support\Providers\EventServiceProvider;
11use Illuminate\Routing\Events\ResponsePrepared;
12 
13class HybridCacheEventServiceProvider extends EventServiceProvider
14{
15 /**
16 * The event to listener mappings for the application.
17 *
18 * @var array<class-string, array<int, class-string>>
19 */
20 protected $listen = [
21 ResponsePrepared::class => [
22 ResponsePreparedListener::class,
23 ],
24 Login::class => [
25 LoginSuccessListener::class,
26 ],
27 Logout::class => [
28 LoggedOutListener::class,
29 ],
30 ];
31}

With our new event listeners created and the event service provider updated, let us take a moment and log into the Statamic Control Panel and inspect the cookies sent along with the response:

An image of an encrypted cookie

If we look carefully at the value of our cookie in the screenshot, we will find that it is encrypted, along with the rest of the cookies created by Laravel and Statamic. It's great that this happens by default, but we will need to be able to read the value of this cookie before Laravel is available. We could find some way to decrypt this outside of Laravel, but at that point, we're working too hard reinventing the wheel to make things worth it.

Attempting to update the $except array App\Http\Middleware\EncryptCookies middleware was producing inconsistent results during initial development, and I was not too fond of the idea of having to remember to update it for each site I used the hybrid cache on anyway. To solve both issues, I added an "after resolving" callback to Laravel's service container, which lets us execute some code whenever Laravel creates an instance of some class.

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 public function register()
14 {
15 $this->app->singleton(Manager::class, function () {
16 if (Manager::$instance != null) {
17 return Manager::$instance;
18 }
19 
20 return new Manager();
21 });
22 
23 $this->app->afterResolving(EncryptCookies::class, function (EncryptCookies $encryptCookies) {
24 $encryptCookies->disableFor('X-Hybrid-Cache');
25 });
26 }
27 
28 // ...
29 
30}

With our new afterResolving callback in place, our cookie value is now in plaintext after logging in:

Image showing an cookie containing the plaintext value true

After logging out of the Control Panel, we can also see that our cookie value is now false:

Image showing an cookie containing the plaintext value false

We can now determine if a user is logged in using our new cookie; if they are, we will bypass the cache.

In app/HybridCache/Manager.php:

1<?php
2 
3namespace App\HybridCache;
4 
5class Manager
6{
7 // ...
8 
9 public function isCacheBypassed(): bool
10 {
11 return $_COOKIE && array_key_exists('X-Hybrid-Cache', $_COOKIE)
12 && $_COOKIE['X-Hybrid-Cache'] == 'true';
13 }
14 
15 public function canHandle(): bool
16 {
17 if ($this->isCacheBypassed()) {
18 return false;
19 }
20 
21 if ($this->getIgnoreQueryStrings()) {
22 if (array_key_exists('QUERY_STRING', $_SERVER)) {
23 return false;
24 }
25 }
26 
27 // ...
28 }
29 
30 // ...
31 
32}

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 *
24 * @see \App\HybridCache\Facades\HybridCache
25 */
26class HybridCache extends Facade
27{
28 protected static function getFacadeAccessor()
29 {
30 return Manager::class;
31 }
32}

With these updates in place, logging into our Statamic site should now automatically bypass our aggressive cache system. What's great about this is that we can simplify having speedy pages for non-authenticated users but maintain the flexibility of dynamic pages within our member areas and utilize other great features, such as Statamic's cache tag if we need to.

Our current cache bypass implementation works, but if we were to log into the Statamic Control Panel and let it sit until the session expired, we would find that our X-Hybrid-Cache cookie value gets stuck at true, and won't reset until we sign back in and log out, or clear our cookies.

A simple way to solve this is to update our ResponsePreparedListener and set the value of X-Hybrid-Cache to false anytime we make it past the authentication check.

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 if (request()->ajax()) {
16 return;
17 }
18 
19 if (! HybridCache::canCache()) {
20 return;
21 }
22 
23 // Don't cache responses for authenticated users.
24 if (Auth::check()) {
25 return;
26 }
27 
28 cookie()->queue(cookie()->forever('X-Hybrid-Cache', 'false'));
29 
30 // ...
31 }
32}

While it is not the most extravagant fix for this issue, it does prevent users from being permanently stuck in cache bypass.

To round out this section, let's create a custom Antlers tag that template authors can use to check if the cache is currently bypassed.

In app/Tags/HybridCache.php:

1<?php
2 
3namespace App\Tags;
4 
5use App\HybridCache\Facades\HybridCache as Cache;
6use Statamic\Tags\Tags;
7 
8class HybridCache extends Tags
9{
10 protected static $handle = 'hybrid_cache';
11 
12 public function bypassed()
13 {
14 return Cache::isCacheBypassed();
15 }
16 
17 public function ignore()
18 {
19 Cache::abandonCache();
20 }
21 
22}

With our new tag in place, we can now use it in templates like so:

1{{ if {hybrid_cache:bypassed} }}
2 The cache was bypassed!
3{{ else }}
4 The cache was not bypassed.
5{{ /if }}

You may have also noticed we added an ignore method. We can use this to have pages excluded from our cache directly within a template:

1{{ if some_condition }}
2 {{ hybrid_cache:ignore }}
3{{ /if }}
Get the PDF version on LeanPub Grab the example code on GitHub Proceed to Creating a Hybrid Cache System for Statamic: Part Four

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.