Self Clearing Static Cache in Laravel

Since January 2023 I've been rebuilding my wife's Coeliac Sanctuary website in my spare time around work. It's been a long, slow process, especially with the arrival of my daughter in March 2024, but the project is now nearing completion, and we hope to launch in summer or maybe autumn 2025.

During the course of the rebuild I implemented some technical features I'm quite proud of, and I hope to write about them here.

For some background, Coeliac Sanctuary is a monolithic Laravel application, it includes blogs, recipes, an eating out guide/directory, listing where people can safely eat out gluten free in the UK and Ireland, and an online shop, the new version currently in development is written in the VILT stack (Laravel, VueJS, InertiaJS and Tailwind CSS).

What is Self Clearing Static Cache

This is a caching approach where data is stored indefinitely and only cleared when the underlying model changes — no timers, no guesswork.

A lot of the content on Coeliac Sanctuary is technically dynamic, but practically static. Take the homepage for example, it displays the six most recent blog posts. That content doesn't change unless a new blog is published — which might happen once a week, or it could be next month. Between those times, it's the same data, every single page load.

Laravel's built-in cache system is excellent and offers options like time-based expiry and even graceful fallback with the new flexible cache options. But I needed more control. My cached data might stay valid for hours, or weeks - there's no predictable expiry.

That's where the idea of a self-clearing cache came in: let the data stay cached forever, and only clear it when the source model updates.

As I said, the cached data is valid, until a model is saved, and that is where the logic lives, on the Model itself, in the Coeliac Sanctuary codebase I have a Cacheable trait.

    <?php

/** @mixin Model */
trait Cacheable
{
  public static function bootCacheable(): void
  {
    static::saved(function (self $model): void {
      dispatch(function () use ($model): void {
        /** @var string[] $keys */
        $keys = config("coeliac.cacheable.{$model->cacheKey()}");

        foreach ($keys as $key) {
          if (preg_match('/(.*)\.\{([a-z.]+)}/', $key, $matches)) {
            $wildcard = $matches[2];
            $bits = explode('.', $wildcard);
            $column = array_pop($bits);

            $record = $model;

            foreach ($bits as $bit) {
              $record = $record->$bit;
            }

            if ( ! $record) {
              continue;
            }

            $key = str_replace("{{$wildcard}}", $record->$column, $key);

            Cache::delete($key);

            continue;
          }

          Cache::delete($key);
        }
      });
    });
  }

  abstract protected function cacheKey(): string;
}
    

It might look quite complex, but in reality, it's quite simple. At its most basic level, it hooks into the Saved event dispatched by the Model, and then looks in a config file to get a list of cache keys for that model, and then clears those keys from the cache.

In some cases, the static cache might have a wildcard in it, for example on each 'county' page in the eating out guide, I list the top most rated places in that county, and that needs caching individually, the cache key for that looks like coeliac.eating-out.top-rated-in-county.{county.slug} - notice the county.slug in the curly braces, when the cache clearing logic discovers those, it knows it needs to go in to the county relationship, and then use the slug property in place of the {county.slug} placeholder, and this can go infinite levels deep, meaning in the cache, the keys are for example coeliac.eating-out.top-rated-in-county.cheshire, coeliac.eating-out.top-rated-in-county.north-yorkshire and so on.

The trait also has an abstract method that tells the logic what area in the cache to get the keys from it needs to clear, more on that later.

How do you write to the cache?

This is actually quite simple, I get the key needed out of the config, whether that's a static key or a wildcard key, and I just use Cache::rememberForever($key, fn () => $value), nothing special going on, and that does exactly what I want, remembers the value forever, until it is cleared by the Cacheable trait.

For example, here is the action that gets the latest blogs for the homepage as discussed before.

    <?php

namespace App\Actions\Blogs;

class GetLatestBlogsForHomepageAction
{
  public function handle(): AnonymousResourceCollection
  {
    /** @var string $key */
    $key = config('coeliac.cacheable.blogs.home');

    /** @var AnonymousResourceCollection $blogs */
    $blogs = Cache::rememberForever(
      $key,
      fn () => BlogSimpleCardViewResource::collection(Blog::query()
        ->take(6)
        ->latest()
        ->with(['media'])
        ->get())
    );

    return $blogs;
  }
}
    

By returning Cache::rememberForever(...) I'll either get the value that is currently in the cache, or I'll get the result from the executed closure that is sent to be cached.

The same functionality is used for the wildcarded cache keys, for example here is the action that gets the top rated places in a given county, it just uses a simple str_replace to replace the wildcard placeholder with the actual value.

    <?php

namespace App\Actions\EatingOut;

class GetTopRatedPlacesInCountyAction
{
  /** @return Collection */
  public function handle(EateryCounty $county): Collection
  {
    $key = str_replace('{county.slug}', $county->slug, config('coeliac.cacheable.eating-out.top-rated-in-county'));

    return Cache::rememberForever($key, fn () => app(CountyReviewsQuery::class)($county, 'rating desc, rating_count desc'));
  }
}
    

How does the system know what to clear?

I have a coeliac.php file in my config folder with some more values related to the website, but at the bottom there's an entire key related to cacheable, which has subkeys coming off for each area of the website, which then has an array of keys for that website.

If you remember above, in the Cacheable trait, there is that abstract method of cacheKey(), that is what that relates to, so for example the Blog model will have the cacheKey() of blogs.

    <?php

return [
  // ...

  'cacheable' => [
    'blogs' => [
      'home' => 'cache.blogs.home',
      'tags' => 'cache.blogs.tags',
      'site-map' => 'cache.blogs.site-map',
    ],
    'recipes' => [
      // ...
    ],
    'collections' => [
      // ...
    ],
    'eating-out' => [
      'home' => 'cache.eating-out.home',
      'top-rated' => 'cache.eating-out.top-rated',
      'most-rated' => 'cache.eating-out.most-rated',
      'index-counts' => 'cache.eating-out.index-counts',
      'stats' => 'cache.eating-out.stats',
      'top-rated-in-county' => 'coeliac.eating-out.top-rated-in-county.{county.slug}',
      'most-rated-in-county' => 'coeliac.eating-out.most-rated-in-county.{county.slug}',
      'site-map-counties' => 'coeliac.eating-out.site-map.counties',
      'site-map-towns' => 'coeliac.eating-out.site-map.towns',
      'site-map-eateries' => 'coeliac.eating-out.site-map.eateries',
      'site-map-nationwide' => 'coeliac.eating-out.site-map.nationwide',
      'site-map-nationwide-branches' => 'coeliac.eating-out.site-map.nationwide-branches',
    ],
    // ...
  ],
];
    

Isn't there a performance hit when the cache is cleared?

As with any cached data, the 'unlucky' user who hits the page just after cache has cleared may hit a small performance hit while the data is retrieved from the database and cached, that's inevitable, and it's also one thing that the flexible cache in Laravel helps solve, but from my point of view, if one users page load might take a few milliseconds longer so everyone else can have a quicker experience, then that's fine.

How often is cache cleared?

With the website currently still being in the internal testing phase, thats not something I can answer yet, but I'm very excited to look at metrics in Laravel Nightwatch once the website is released to see how often the cache is hit or missed.

For most of the cached data, we're in control, it doesn't clear until we post another blog, or recipe, but for the eating out guide, for example when a review is left on a location in our database, I call $review->eatery->touch() to update the updated_at timestamp on the Eatery model, which in turn will clear all cache keys under eating-out alongside all cache keys under eating-out-reviews (The EateryReview model) because that newest review might have pushed that eatery into the top 3 highest rated places in that county, and that data is now stale and needs regenerating.

Should I use something similar in my app?

That really depends on what kind of app you have, for Coeliac Sanctuary, we wanted to load the page as fast as possible, shaving milliseconds here or there, and getting the data from Redis is far faster than getting it from the MySQL database, so for what dynamically static data I can cache, data that is from the database that doesn't change often, then it gets cached until that cache becomes stale.

If your app has similar functionality, for example displays your recent posts on your homepage, and it's a high traffic app, you care about SEO and want to shave a few milliseconds off your load time, then yea, I think something like this could be a great help, but as with all things, its worth testing, and weighing up the pros and cons.