September 3, 2023 —John Koster
This article is part of a series. If you'd like to read a different part first, check out the following links:
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(): mixed42 {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\HybridCache27 */28class HybridCache extends Facade29{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:
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 ResponsePreparedListener12{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(): void10 {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 Tags10{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:
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.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 }}
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 ServiceProvider12{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 ResponsePreparedListener12{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\HybridCache28 */29class HybridCache extends Facade30{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(): void13 {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 ServiceProvider13{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.
∎
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.