Skip to content

Laravel page cache invalidation: clear only the pages a save changed ​

A full-page cache is the cheapest speed-up a content site has. A guest asks for /, you send HTML from the cache, and PHP never touches the database. The hard part is knowing when to throw a page away.

Most setups pick one of two answers. Either every save clears the whole cache, or every page expires after a few minutes. The first throws away a thousand good pages because an editor fixed a typo in a draft. The second shows a stale page until the timer runs out.

There is a third answer. Each page remembers what it read, and each save says what it wrote. A page is thrown away only when a save touched something it read. A draft saved leaves the home page alone, because the home page lists published posts only. A published post saved moves the home page, and moves that post's own page, but not the page of the post next to it.

This post builds that in about 200 lines of Laravel: DB::listen, model events and a cache store. The whole example runs as a test suite, which is at the end.

Names for what a page read ​

Everything hinges on one idea: a read and a write must name the same thing the same way. We use three kinds of keys:

  • posts: the whole table, for a read we cannot narrow down (select count(*) from posts);
  • posts:id=5: one row, for Post::find(5);
  • posts:status=publish: a list, for Post::where('status', 'publish').

A read records the most specific key it can prove. A write emits every key it could have changed: the table, the row, and each list the row was in before and after the save.

The rule that keeps this honest: when unsure, take a coarser key, never a finer one. A coarse key only costs a cache miss, while a key that is too fine shows a stale page.

Keys from SQL ​

Every query passes through DB::listen with its SQL and bindings. We read the SQL only as far as we can be sure of it:

php
namespace App\PageCache;

use Illuminate\Database\Eloquent\Model;

final class DependencyKeys
{
    /** Tables that show on pages: the primary key first, then the columns lists are asked by. */
    public const TRACKED = [
        'posts' => ['id', 'status'],
    ];

    /** @return list<string> */
    public static function forRead(string $sql, array $bindings): array
    {
        $sql = strtolower(str_replace(['"', '`'], '', $sql));

        preg_match_all('/\b(?:from|join) (\w+)/', $sql, $matches);
        $tables = array_values(array_intersect(array_unique($matches[1]), array_keys(self::TRACKED)));

        if ($tables === []) {
            return [];
        }

        // Anything we cannot read for sure falls back to the whole table: a coarser key, never a finer one.
        if (count($matches[1]) > 1 || preg_match('/\b(or|not|union|exists)\b|\(select/', $sql)) {
            return $tables;
        }

        $table = $tables[0];
        [$primary, $columns] = [self::TRACKED[$table][0], self::TRACKED[$table]];
        $equals = [];

        preg_match_all('/(?:\w+\.)?(\w+) = \?/', $sql, $conditions, PREG_OFFSET_CAPTURE);

        foreach ($conditions[1] as [$column, $offset]) {
            $position = substr_count(substr($sql, 0, $offset), '?');

            if (in_array($column, $columns, true) && array_key_exists($position, $bindings)) {
                $equals[$column] = $bindings[$position];
            }
        }

        $key = match (true) {
            isset($equals[$primary]) => "{$table}:{$primary}={$equals[$primary]}",
            $equals !== [] => $table.':'.array_key_first($equals).'='.reset($equals),
            default => null,
        };

        return $key === null ? [$table] : [$key, "{$table}#all"];
    }
}

A few things make this safe rather than clever:

  • Only = in a plain and counts. If the query also says created_at > ?, we ignore that part. The key posts:status=publish covers more rows than the query read, which is the safe direction.
  • or, not, a join or a subquery means the whole table. Reading those properly takes a real SQL parser. Without one, the table key is the honest answer.
  • Tables you do not list are ignored. Sessions, jobs and the cache table are on no page.
  • posts#all comes with every narrow key. That is the escape hatch for writes we cannot see into, explained below.

Keys from a save ​

Eloquent writes come with the model, so we know exactly what changed:

php
    /** @return list<string> */
    public static function forModel(Model $model): array
    {
        $table = $model->getTable();

        if (! isset(self::TRACKED[$table])) {
            return [];
        }

        [$primary, $columns] = [self::TRACKED[$table][0], array_slice(self::TRACKED[$table], 1)];
        $keys = [$table, "{$table}:{$primary}={$model->getKey()}"];

        foreach ($columns as $column) {
            // Before and after: a post leaving a list moves it as much as one joining it.
            foreach ([$model->getOriginal($column), $model->getAttribute($column)] as $value) {
                if ($value !== null) {
                    $keys[] = "{$table}:{$column}={$value}";
                }
            }
        }

        return array_values(array_unique($keys));
    }

In the saved event, getOriginal() still holds the values from before the save. So unpublishing a post emits both posts:status=publish and posts:status=draft, and the home page drops it.

A write that skips the model, such as DB::table('posts')->update(...) or Post::where(...)->update(...), gives us only SQL. We cannot tell which rows changed or what their status was, so it moves the whole table and every narrow key of it:

php
    /** @return list<string> */
    public static function forRawWrite(string $sql): array
    {
        $sql = strtolower(str_replace(['"', '`'], '', $sql));

        if (! preg_match('/^(?:insert into|update|delete from) (\w+)/', $sql, $match) || ! isset(self::TRACKED[$match[1]])) {
            return [];
        }

        // No idea which rows changed: the table, and every key of it.
        return [$match[1], "{$match[1]}#all"];
    }

A version per key, not a list of pages per key ​

The obvious design keeps a list of pages for each key and deletes them on a write. That needs a reverse index, cleanup, and locking when two servers write at once.

There is a simpler way. Keep one integer per key. A cached page stores the version of each key it read. On a hit, one many() call fetches the current versions. If any of them moved, it is a miss. A write only increments.

php
namespace App\PageCache;

use Illuminate\Support\Facades\Cache;

final class KeyVersions
{
    /** @return array<string, int> */
    public function current(array $keys): array
    {
        $keys = array_values(array_unique($keys));
        $stored = $keys === [] ? [] : Cache::getStore()->many(array_map(fn ($key) => 'dep:'.$key, $keys));

        return array_combine($keys, array_map(fn ($key) => (int) ($stored['dep:'.$key] ?? 0), $keys)) ?: [];
    }

    public function moved(array $seen): bool
    {
        return $this->current(array_keys($seen)) != $seen;
    }

    public function bump(array $keys): void
    {
        foreach (array_unique($keys) as $key) {
            Cache::getStore()->increment('dep:'.$key);
        }
    }
}

It works with any store that can increment: Redis, Memcached, database, file. Several servers sharing one store agree without coordinating. An evicted version reads as 0, which is a miss, the safe direction again.

One detail we learned the hard way: call Cache::getStore(), not Cache::many(). The repository fires a cache event for every key, and a page holds dozens of keys. With Laravel Pulse recording cache events, that doubled the time of a hit.

The request's log ​

What a page read is per request, so it lives in a scoped() binding. That keeps it safe on Octane, where a singleton would carry one request's keys into the next:

php
namespace App\PageCache;

final class ReadLog
{
    private ?array $keys = null;

    /** @var array<string, int> */
    private array $modelWrites = [];

    public function start(): void
    {
        $this->keys = [];
    }

    /** @return list<string> */
    public function stop(): array
    {
        [$keys, $this->keys] = [array_keys($this->keys ?? []), null];

        return $keys;
    }

    public function read(array $keys): void
    {
        if ($this->keys !== null) {
            $this->keys += array_fill_keys($keys, true);
        }
    }

    public function expectModelWrite(string $table): void
    {
        $this->modelWrites[$table] = ($this->modelWrites[$table] ?? 0) + 1;
    }

    public function isModelWrite(string $table): bool
    {
        if (($this->modelWrites[$table] ?? 0) === 0) {
            return false;
        }

        $this->modelWrites[$table]--;

        return true;
    }
}

expectModelWrite() solves a small puzzle. A model save also runs an update statement, and DB::listen sees it as a raw write. If we let it through, every model save would move posts#all and throw away every page of the table. So the updating event says the next statement on this table is the model's, and the listener lets that one statement pass. The model's own saved event then emits the precise keys.

Wiring it up ​

php
// AppServiceProvider
use App\PageCache\DependencyKeys;
use App\PageCache\KeyVersions;
use App\PageCache\ReadLog;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;

public function register(): void
{
    $this->app->scoped(ReadLog::class);
}

public function boot(): void
{
    DB::listen(function (QueryExecuted $query): void {
        $log = $this->app->make(ReadLog::class);

        if (str_starts_with(strtolower(ltrim($query->sql)), 'select')) {
            $log->read(DependencyKeys::forRead($query->sql, $query->bindings));

            return;
        }

        $keys = DependencyKeys::forRawWrite($query->sql);

        if ($keys !== [] && ! $log->isModelWrite($keys[0])) {
            $this->app->make(KeyVersions::class)->bump($keys);
        }
    });

    Event::listen(['eloquent.creating: *', 'eloquent.updating: *', 'eloquent.deleting: *'], function (string $event, array $data): void {
        if (isset(DependencyKeys::TRACKED[$data[0]->getTable()])) {
            $this->app->make(ReadLog::class)->expectModelWrite($data[0]->getTable());
        }
    });

    Event::listen(['eloquent.saved: *', 'eloquent.deleted: *'], function (string $event, array $data): void {
        $this->app->make(KeyVersions::class)->bump(DependencyKeys::forModel($data[0]));
    });
}

The middleware ​

The middleware caches only guests' GET requests that answer 200, and only on routes you give it:

php
namespace App\Http\Middleware;

use App\PageCache\KeyVersions;
use App\PageCache\ReadLog;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpFoundation\Response;

final class CachePages
{
    public function __construct(private ReadLog $log, private KeyVersions $versions) {}

    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->isMethod('GET') || $request->user() !== null) {
            return $next($request);
        }

        $key = 'page:'.sha1($request->fullUrl());
        $page = Cache::get($key);

        if (is_array($page) && ! $this->versions->moved($page['deps'])) {
            return response($page['body'])->header('Content-Type', $page['type'])->header('X-Page-Cache', 'hit');
        }

        $this->log->start();
        $response = $next($request);
        $keys = $this->log->stop();

        if ($response->getStatusCode() === 200) {
            Cache::put($key, [
                'body' => $response->getContent(),
                'type' => $response->headers->get('Content-Type'),
                'deps' => $this->versions->current($keys),
            ], now()->addHour());
        }

        $response->headers->set('X-Page-Cache', 'miss');

        return $response;
    }
}
php
Route::middleware(CachePages::class)->group(function () {
    Route::get('/', HomeController::class);
    Route::get('/posts/{post}', [PostController::class, 'show']);
});

Prove it with tests ​

The tests describe the behaviour better than prose does:

php
it('leaves the home page cached when a draft is saved', function () {
    Post::create(['title' => 'Hello', 'status' => 'publish']);
    $draft = Post::create(['title' => 'Draft', 'status' => 'draft']);

    $this->get('/')->assertHeader('X-Page-Cache', 'miss');
    $this->get('/')->assertHeader('X-Page-Cache', 'hit');

    $draft->update(['title' => 'Better draft']);

    $this->get('/')->assertHeader('X-Page-Cache', 'hit');
});

it('moves a post page only with its own post', function () {
    [$one, $two] = Post::factory()->published()->count(2)->create();
    $this->get("/posts/{$one->id}");
    $this->get("/posts/{$two->id}");

    $two->update(['title' => 'Two, edited']);

    $this->get("/posts/{$one->id}")->assertHeader('X-Page-Cache', 'hit');
    $this->get("/posts/{$two->id}")->assertHeader('X-Page-Cache', 'miss');
});

it('drops an unpublished post from the list', function () {
    $post = Post::create(['title' => 'Soon', 'status' => 'publish']);
    $this->get('/')->assertSee('Soon');

    $post->update(['status' => 'draft']);

    $this->get('/')->assertDontSee('Soon');
});

it('moves every page of the table on a raw update', function () {
    $post = Post::create(['title' => 'One', 'status' => 'publish']);
    $this->get('/');

    DB::table('posts')->where('id', $post->id)->update(['title' => 'Renamed']);

    $this->get('/')->assertSee('Renamed');
});

We also broke the code on purpose to check that the tests notice. Without the row key, the draft test and the post page test fail. Without the "before" value of status, the unpublish test fails. Without posts#all on raw writes, the raw update test fails.

What this does not do ​

  • A race between drawing and storing. The versions are taken after the page is drawn. If a save lands between the query and that moment, the page is stored with the new version but the old content, and it stays until its TTL runs out. Keep the TTL short enough that this is acceptable, or take the versions as each query runs, at the cost of one cache call per query.
  • Joins and or are table-wide. A real parser narrows them too. Until you write one, add the columns your lists filter by to TRACKED, and look at which queries still fall back to the whole table.
  • Pages that depend on the visitor (a cart count, "Hello, Ann") must stay out of the cache, or draw that part with JavaScript.
  • Keys live for good. That is one small integer per row or list that anyone ever asked about. On Redis it is nothing, but on a store that evicts, an evicted key is a miss.

Where this comes from ​

This is a smaller version of the page cache in kitloom, a Laravel CMS on WordPress's tables. There, a proper SQL parser reads joins, or and subqueries, and lists are keyed by combinations of columns such as post_type and post_status. Static HTML files carry their own keys too. In its benchmark, with an editor saving posts during the run, about half as many requests missed the cache as with "every save clears everything", for about a millisecond more per hit. The idea needs none of that, though: DB::listen, two model events and one integer per key.

Last updated:

kitloom — modules and themes for Laravel.
Contact · Pricing · Services · License · Refunds · Privacy · Community · YouTube · [email protected]