Skip to content

feat!: derive invalidation from what pages read, not from configuration - #3

Open
Bahbv wants to merge 22 commits into
mainfrom
v2
Open

feat!: derive invalidation from what pages read, not from configuration#3
Bahbv wants to merge 22 commits into
mainfrom
v2

Conversation

@Bahbv

@Bahbv Bahbv commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Invalidation is now derived from what pages actually read, instead of from rules describing what they might read. There is no configuration left to write.

Why

The 1.x design kept a hand-maintained map of block types and field handles that had to mirror the templates. It drifted silently. Two examples from meerdervoort's own config: a comment recording that /projecten and /vacatures invalidated nothing because the URIs no longer resolved, and a ['collection' => 'articles', 'field' => 'author'] rule that existed purely to patch a hole the block index had by construction — it only ever read $entry->get('pagebuilder'), so an entry's author, category, hero fieldset and Bard entry links were invisible to it.

The template already knows what it renders. v2 observes that rather than restating it.

How it works

While a page renders, reads are recorded as tags against its URL when it enters the static cache: entry:{id}, collection:{handle}, term:{taxonomy}::{slug}, taxonomy:{handle}, global:{handle}, form:{handle}. On save the changed item resolves to the same tags and every URL carrying one is cleared.

The distinction that keeps it targeted: a query pinned to ids or a uri records only those items, so a reusable block embedded on three pages clears exactly those three. Any other query also records a list tag for its scope, because an entry created tomorrow has an id that is in no tag set yet — that is what clears a "latest three articles" carousel.

Nesting is free. A reusable block's reads are the embedding page's reads at any depth, so a page embedding a block that pulls in a global and another entry carries all three dependencies.

Breaking changes

  • Every rule key is gone: pagebuilder_collections, collection_entry_rules, collection_urls, globals_flush_all, navs_flush_all, collection_trees_flush_all, forms_flush_all, global_target_blocks, global_urls, taxonomy_target_blocks, taxonomy_urls.
  • The block index and its supporting classes are removed, along with customEntryUrls().
  • The cache is no longer flushed wholesale for globals, navigations, form blueprints or collection trees. URLs are invalidated individually so nocache regions survive. A navigation save still clears every cached URL — a reorder changes links in shared layout and no per-page dependency can express that — but it clears rather than flushes.

Upgrade path

Nothing is required beyond composer update. Delete the rule keys from config/cache_invalidation.php when convenient; cache-invalidation:doctor lists any still present.

A statamic.static_caching.invalidation.class pinned at ContentDependencyInvalidator can stay — the addon recognises its own class names and upgrades the pin, so a site that pins the removed class does not fatal. A subclass of your own is still respected.

Expect one round of broad invalidation after deploying, while the graph fills.

Also fixed

  • Invalidator::refresh() is honoured. DefaultInvalidator flips a protected flag before delegating to invalidate(), which 1.x overrode without checking, so static_caching.background_recache hard-purged instead of refreshing.
  • Relations rendered outside the pagebuilder field now invalidate.
  • Form blueprint saves clear the pages rendering that form instead of the entire site.

New

cache-invalidation:why, :affected, :stats, :clear and :doctor. affected answers "what clears if I save this?" before saving — the question the 1.x design could not be asked. doctor exits non-zero when invalidation cannot work, so a broken environment fails a deploy instead of quietly serving stale pages.

CacheTags::add() / ::invalidate() and @cachetags(...) cover data reaching a template from outside Statamic's repositories, where nothing observes the read and nothing knows when it changes.

Surviving Statamic upgrades

Recording works by subclassing Statamic internals, and several of the members it uses are protected rather than public API — getFilteredKeys(), getItems(), Tags\Nav::structure(), TermRepository::ensureAssociations(), Variables::newAugmentedInstance(). Semver does not cover those, so a minor Statamic release can move them.

cache-invalidation:selftest makes that checkable from inside a site, which is where it matters — the addon's own suite cannot run there, because its dev dependencies are never installed as a transitive dependency. It checks each seam twice: structurally by reflection, that the methods and properties still exist, and behaviourally by recording against the site's own content and asserting the tags that come back. The second is the important one. A method can survive a rename and still behave differently, and every bug found while building this was of exactly that kind.

composer update statamic/cms
php artisan cache-invalidation:selftest

Read-only, skips checks the site has no content for rather than failing them, and exits non-zero so it can gate an upgrade in CI. Verified by reinstating two of the fixed bugs against a real site: each fails the matching check and names the leaked tag in the output.

The README lists every Statamic class this extends and the member it relies on, so the blast radius is reviewable without reading the source.

Verification

115 tests, 195 assertions. Recording is asserted through real queries against real content and invalidation through real save events, including one test that drives a real HTTP request through Statamic's frontend and cache middleware.

That choice is not stylistic. Three defects were found only because the tests refused to use synthetic inputs, and each would have shipped silently:

  1. Every rendered page depended on every collection. Statamic resolves each frontend request through findByUri(), an unscoped where('uri', ...) query, so the list-tag rule marked every page as depending on every collection — any entry save anywhere cleared the whole cache, quietly degrading the addon to the behaviour it replaces. Only a real HTTP render exposed it.
  2. config:cache broke the addon entirely. mergeConfigFrom is a no-op with cached config, so a site that followed the install instructions — which say there is nothing to publish — had no config namespace at runtime, an empty sqlite path, and a queued invalidation job that threw. Pages stayed stale and nothing reported it.
  3. Reusable blocks over-invalidated. The entries fieldtype augments through a StatusQueryBuilder whose status filter adds a nested clause only when the queried ids resolve, so a query built from invented ids looked simple and the buggy rule passed. With real content it leaked a list tag.

The suite was mutation-checked rather than assumed to bite: removing the untracked safety net, the nav clear-all branch, the overflow tag, the graph lookup or getItemUrls each fails exactly the tests that should catch it.

Not covered

  • Assets are not tracked; saving one clears nothing extra, matching 1.x.
  • Globals are not scoped per site, so on a multisite install saving one clears the pages that read it across every site. Over-invalidation, not staleness.
  • Concurrency has not been exercised under load. WAL is enabled on the sqlite driver.
  • Not yet run on a production site with a real queue worker — that is the intended next step before tagging.

Bahbv and others added 22 commits August 6, 2026 11:24
Groundwork for config-free invalidation. Records only: the existing
config-driven invalidator is still authoritative, so behaviour is unchanged.

Pages are keyed on the absolute URL at the moment they enter the static cache,
which is the only point that knows both the canonical URL and that the page is
really being cached — and it is reached identically by the file and application
drivers, so full and half measure need no separate handling.

The cachers are subclassed rather than decorated. Statamic's cache middleware
branches on `instanceof ApplicationCacher`, `FileCacher`, `NullCacher` and
`AbstractCacher`; a wrapper around the Cacher binding would silently change
which responses get cached and break exclusion checks. Registering through
StaticCacheManager::extend() also means Statamic hands us the same fully merged
strategy config its own createXDriver() methods receive, so exclusions, query
string handling and locale are not reconstructed here.

Storage defaults to a dedicated sqlite file the addon owns, registered as its
own connection. A Statamic site is commonly flat-file with DB_CONNECTION unset,
and invalidation must not depend on the host app having provisioned a database.
It sits next to Statamic's own static cache bookkeeping so the graph and the
cache share a directory, and a deploy discarding one discards both. A database
driver is available for sites that would rather keep the graph in their app
database; identity there is a sha1 of the URL, since MySQL cannot index TEXT
without a prefix length and a prefix index would make the unique constraint
wrong for URLs sharing a long path.

Recording is best effort — a failed graph write logs and continues rather than
500ing a visitor's page. A missing row leaves the URL untracked, which the
safety net clears on the next save, so the failure mode is over-invalidation
rather than stale content. Pages exceeding the tag cap collapse to a single
overflow tag and are treated as depending on everything, which is lossy in the
same safe direction.

Observability ships with the mechanism, since a graph you cannot inspect is
worse than config you can read: `why` shows what a page depends on, `stats`
reports coverage against the static cache, and `doctor` exits non-zero when
invalidation cannot work, so a broken environment fails a deploy instead of
quietly serving stale pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Still recording only — the config-driven invalidator remains authoritative.

Hooks getFilteredKeys() and getItems() rather than get(). getFilteredKeys() is
reached by every read path, including count() and pluck(), which bypass get()
entirely in Stache\Query\Builder; getItems() is reached only by get() and only
with keys that already have limit and offset applied, so item tags describe what
was rendered rather than everything that matched.

The item-lookup heuristic is the basis for targeting. A query pinned to ids can
only be affected by those items, so it records item tags alone; anything else can
gain a result once an entry is *created*, whose id is in no tag set yet, so it
must also record the list tag for its scope. An empty where clause is explicitly
not an id lookup — "everything in this collection" is the broadest list query
there is. where('collection', ...) never reaches $wheres, since
EntryQueryBuilder intercepts it into $collections first, so it cannot fool the
check.

Verified against meerdervoort's content: a list query records the collection plus
each rendered entry; Entry::find() and whereIn('id') record item tags only;
count() and pluck() record the list tag; limit(3) records the collection plus
exactly three entries; a taxonomy query records the taxonomy plus each term while
Term::find() records the term alone.

The term repository has to be replaced wholesale rather than rebound, because
TermRepository::query() constructs its builder directly instead of resolving it.
Its protected ensureAssociations() still runs — without it taxonomy queries
return nothing.

Read recorders are registered in boot, not register: Statamic's Stache provider
binds EntryQueryBuilder unconditionally during its own register(), so a binding
made there would be clobbered if our provider happened to run first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the global and form recorders and an invalidator that resolves URLs from the
recorded graph. Off by default: the graph can be recorded and inspected with
`stats` and `affected` before it decides anything.

Globals turn out to be trackable after all, which removes the last reason to keep
a config list. The variables store builds its items through app(Variables::class),
so a subclass reaches every set, and AbstractAugmented::transientValue() calls
get() — so hooking get() alone covers __get, offsetGet, __call and enumeration via
toDeferredAugmentedArray(). That last path resolves lazily, which is what makes
this worth doing: Statamic hydrates every global set into every view whether a
template touches it or not, so tagging at hydration would mark every page as
depending on every global. Instead a set used in the layout lands on every page
and a set used in one block lands on that block's pages — the distinction
globals_flush_all previously had to be told by hand.

Forms hook find(), which is how Forms\Fieldtype::augmentValue() resolves them.
all() goes through self::find() and so binds to the parent class, which is
convenient: it is control panel territory and should not mark a page as depending
on every form.

Navigation is the one deliberate blunt instrument, per the spec. It clears every
cached URL rather than flushing, so nocache regions and the graph survive and
pages return without a full re-render storm.

Two changes of mind while building:

Dropped the planned UrlInvalidated pruning listener. It fires once per URL, so a
nav save would become thousands of individual writes on the save path, and it buys
no correctness: rows are replaced when a URL is rendered again, and a row for a
URL that is no longer cached only leads to invalidating something already gone.
StaticCacheCleared does the same job in one DELETE.

The addon now claims the invalidator when the configured class is one of its own,
not only when the config is null. Sites pin it by name — meerdervoort pins v1's
ContentDependencyInvalidator — and on upgrade that pin would silently keep the old
behaviour while the config said otherwise. A genuinely foreign subclass is still
respected.

refresh() now works: DefaultInvalidator flips $refreshing before delegating, and
v1 ignored it and always hard-purged, which silently broke background_recache.

Verified end to end against meerdervoort: saving an article clears pages carrying
collection:articles but not a page depending on one specific employee; saving that
employee clears the reverse; both also clear untracked cached URLs; a nav save
clears everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BREAKING CHANGE: every rule key is gone. Dependencies are observed rather than
declared, so there is nothing to configure.

Deletes the block index and its supporting classes, along with customEntryUrls().
The hook existed for relations the index could not see, and those are now recorded
automatically — the index only ever read $entry->get('pagebuilder'), which made an
entry's author, category, hero fieldset and Bard entry links invisible by
construction.

Adds @cachetags for the one case observation cannot cover: a dependency a template
reacts to without reading, such as a banner conditional on any vacancy existing.

Two upgrade affordances, both prompted by what meerdervoort's config actually looks
like rather than guessed at:

The addon claims the invalidator whenever the configured class is one of its own.
That site pins ContentDependencyInvalidator by name, so deleting the class would
otherwise have fataled on every render; instead the pin follows the addon forward.
A foreign subclass is still respected.

`doctor` reports leftover v1 rule keys. Silently ignoring them would leave a site
believing its rules still mean something, which is the same class of quiet drift
the rewrite exists to remove.

Verified against meerdervoort with its 1.x config and 1.x class pin still in place:
the invalidator resolves to GraphInvalidator, the obsolete keys are listed, and a
real cachePage() writes the row its URL is later found by.

The rollout flag from the previous commit is gone with the code it guarded. The
null driver is the remaining way to be conservative — it records nothing, so every
cached URL is untracked and any save clears everything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolving a reusable block recorded both entry:{id} and
collection:reusable_blocks, so saving any reusable block cleared every page
embedding any of them. Correct, but no better than a per-collection rule, and
worse than what v1's ['block' => 'reusable_block', 'field' => 'entry'] achieved.

The entries fieldtype augments through a StatusQueryBuilder, whose get() applies
whereStatus('published'). That calls ensureCollectionsAreQueriedForStatusQuery(),
which back-fills $collections by mapping the queried ids to their collections, and
then adds a nested per-collection clause. The old column whitelist saw a Nested
where with no column, concluded the query was too complex to reason about, and fell
back to the list tag — using the very $collections that Statamic had just derived
from the ids.

Worth noting this only reproduces with ids that actually resolve: with fake ids the
collection map is empty, no nested clause is added, and the whitelist happened to
work. A unit test over synthetic where clauses would have passed.

Replaces the whitelist with set-bounding, which is both simpler and provably
correct: a query carrying an AND-ed equality clause on `id` can only return a
subset of those ids, so no entry created later can appear in it and no list tag is
needed. Everything ANDed alongside — status, site, Statamic's nested collection
clauses — can only narrow. A top-level OR is the one thing that can admit outside
rows, so it disqualifies the query, as do NotIn and `!=`, which exclude ids rather
than bounding to them.

Verified against meerdervoort: a reusable block now records its item tag alone, and
two pages embedding block A clear while a page embedding block B does not. The list
tag cases are unchanged — carousels, count(), unfiltered queries, by-author
queries and whereNotIn('id') all still record it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cuts the prose around storage drivers, the safety net and the notes to what a
developer needs to install it and predict what a save clears. Adds the transitive
nesting behaviour, which is the question the previous version left unanswered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
61 tests over Testbench and Statamic's AddonTestCase.

Recording is asserted through real queries against real entries, never through
hand-built where clauses. That is not a style preference — it is the lesson from
the list-tag leak fixed in 6966937. Statamic's entries fieldtype augments through a
StatusQueryBuilder whose status filter adds a nested clause only when the queried
ids actually resolve, so a query assembled from invented ids looks simple and the
buggy implementation passed. Verified by restoring the pre-fix implementation: the
augmented-entries-field test fails with the right message, and it also catches a
second latent bug there — whereNotIn('id') was treated as an id lookup, which
under-invalidated, the one direction that serves stale pages.

Invalidation is asserted end to end by calling save() on real content, so the whole
chain runs: Statamic's Invalidate subscriber, the invalidator and the cacher. Pages
are seeded into the cache with a stated tag set rather than rendered, so each test
declares its dependencies in one line.

The suite was mutation-checked rather than assumed to bite. Removing the untracked
safety net, the nav clear-all branch, the overflow tag, the graph lookup, or
getItemUrls each fails exactly the tests that should catch it.

Writing it surfaced three defects, all fixed here:

- Contextual $rules bindings resolve from config, which yields null when the key is
  missing. giveConfig now has a default, and the constructor accepts null, so a
  host app whose static_caching config predates invalidation.rules or sets it to
  null no longer fatals when the invalidator resolves.
- isOwnInvalidator matched a namespace prefix, so it claimed any class under the
  addon namespace rather than only invalidators the addon has shipped. Now an
  explicit list, which is also self-documenting about the removed v1 class.
- The cacher's best-effort recording had never been exercised; there is now a test
  that an unwritable graph does not break a page render.

CI runs the suite on PHP 8.4 and 8.5 alongside the existing syntax check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…atch

@cachetags could declare a dependency but nothing could ever clear it, so it only
worked for tags that happened to map to Statamic items a save already covered.
That left its actual use case — data reaching a template from outside Statamic's
repositories, where nothing observes the read and nothing knows when it changes —
half implemented.

CacheTags now owns both halves: add() records against the current render, and
invalidate() clears every cached URL carrying the given tags, returning the count.
urlsFor() previews without clearing, and cache-invalidation:clear pairs with
cache-invalidation:affected on the command line. The Blade directive compiles to
the same entry point, so there is one public path rather than two.

It deliberately diverges from a content save in one way: it does not sweep up
cached URLs missing from the graph. That safety net exists so routine editing can
never leave a page stale, and the next content save applies it regardless; folding
it in here would make an explicit, targeted call clear the whole cache right after
a deploy, when every URL is untracked.

Mutation-checked, which caught a genuinely untested branch: nothing exercised the
background_recache path, so a regression there would have been silent. Also
documents local development against a real site, which the 2.0 README had dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both would have shipped as silent failures.

Every rendered page recorded a list tag for every collection on the site, so any
entry save anywhere cleared the entire cache — the addon quietly degraded to the
blunt behaviour it exists to replace. Statamic resolves every frontend request
through findByUri(), which queries `uri` with no collection scope, and the
item-lookup rule read that as a set query.

`uri` now counts as an identifying column. Strictly a new entry could claim an
existing URI, but that case is already covered: such an entry carries the URL in
its own absoluteUrl(), which Statamic invalidates directly. What is given up is a
page rendering a teaser resolved by someone else's URI, which would recover on its
next render — a narrow gap in exchange for not clearing everything on every save.

Separately, `php artisan config:cache` makes mergeConfigFrom a no-op, so a site
that followed the install instructions — which say there is nothing to publish —
and then cached its config had no cache_invalidation namespace at runtime. The
sqlite path was empty, mkdir failed, and the queued invalidation job threw: pages
stayed stale and nothing reported it. Defaults are now filled in code, and the
sqlite path and driver are resolved through helpers so nothing assumes the config
namespace exists when the graph is built.

Adds the test that found the first one: a real request through Statamic's frontend
and cache middleware, asserting the graph row describes what the template read —
the page's own entry, the collection it listed, the entry it showed, and the global
it printed — and then that saving that content clears the page while unrelated
saves do not. Everything before this exercised the pieces; this exercises the seam
between them, and it is where both bugs were hiding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both notes come out of the two failures fixed in 1ef0b58, which were only
visible in a real environment rather than in isolation.

doctor belongs in the deploy pipeline because it is the only thing that turns a
broken graph into a failed build instead of quietly stale pages. Workers need
restarting because they hold an open handle on the graph, and a deploy that
replaces storage/ leaves them writing to a file that no longer exists; the
safety net makes that over-invalidation rather than staleness, but it is avoidable.

Also states plainly that the first save after a deploy clears more than usual,
since every cached URL is untracked until it has been rendered again, and that
globals are not scoped per site on a multisite install. Neither is a defect, but
both look like one without a note.

All five commands were already documented; verified against the provider's
registered commands and the signatures in src/Console.
…tion

Two problems that a real site exposed and the harness did not.

Navigation was hardcoded to clear every cached URL. That is right for a nav in the
shared layout and wrong for one used on a few pages, and it made the invalidator
the only place with a special case. Navs are now recorded where they resolve, the
way globals and forms already were, and the invalidator resolves a nav save to its
tag like anything else. A nav in the layout still clears everything, but because it
is recorded on every page rather than because of a rule.

Recording hooks both NavigationRepository::findByHandle() — which is what
Tags\Structure::structure() calls, so the nav tag reaches it — and
NavTreeRepository::find(), for a template holding a Nav object that never goes back
through the repository. all() records every nav it returns. Over-recording is
deliberate here: an unrecorded nav leaves a removed menu item visible to visitors,
while recording too many only clears more pages than needed.

Second, saving any single page cleared the entire site. Resolving a URL in a
structured collection makes Statamic validate the collection tree, and
CollectionStructure::validateTree() plucks every entry in the collection to check
it. That looked like a list query, so every page recorded its own collection's list
tag. The page does not display that list — Statamic reads it to work out which
entry the URL belongs to. findByUri() is now wrapped in a suppression scope that
records the entry it resolved and nothing else.

The suppression is scoped to URL resolution on purpose. A template that walks the
collection tree itself — breadcrumbs, say — still records the dependency, because
it renders it.

Measured on meerdervoort, 213 cached URLs: before, every page save cleared all 213.
After, of 25 pages sampled, 14 clear between 0 and 5 URLs and 11 still clear
everything — and those 11 are the pages in the main navigation, whose titles
genuinely appear on every page. Employee saves clear 8, reusable blocks 2,
articles 10.

Worth recording how the first attempt at a regression test failed. Making the
fixture's collection structured did not reproduce it: in the harness findByUri()
resolves from the Stache uri index and never consults the tree, so the test passed
against the bug. The test that survives asserts the observable contract —
findByUri() records exactly the entry it returned — and the tree-validation path
itself is verified against a real site rather than claimed to be covered here.

Nav recording and the suppression mechanism are mutation-checked: removing the nav
tag from the resolver fails 2 tests, removing the recording fails 4.
Statamic's TreeBuilder resolves every linked entry to build a menu, so a nav in the
shared layout put an entry tag for each of its items on every page. Renaming any
page that appears in the menu then cleared the whole site — the previous commit
removed one cause of that and left this one.

The nav tag now renders inside a suppression scope and re-adds only its own
nav:{handle}. Statamic resolves tag classes through the container, so binding over
Tags\Nav reaches both {{ nav:handle }} and {{ nav for="handle" }} without touching
the tag registry. A nav pointed at a collection structure records that collection's
list tag instead, since the tree is the collection.

This is a trade-off, not a straight win, and it is the one asked for: rename a page
and its menu label stays stale on already-cached pages until they clear for another
reason. Saving the navigation clears them. A page save clears where that page is
rendered as content.

Measured on meerdervoort, 215 cached URLs: 31.9 tags per URL against 68.6 before,
and rows more than halved from 14608 to 6859. A page save clears a median of 1 URL
where it previously cleared all of them — 19 of 25 sampled pages clear 0-5, two
still clear the site because they are genuinely rendered in the layout. Saving
main_nav clears all 215, which is correct: all 215 render it. Employee saves clear
8, reusable blocks 2, articles 10, products 82.

Mutation-checked: dropping the suppression fails the test, and so does dropping the
nav tag it re-adds.
One test per requirement, so the behaviour that was asked for is asserted rather
than inferred from the implementation. Covers the paths that had no test of their
own: navigation reorder, collection tree reorder, published-state changes, an entry
linked from Bard, a form rendered from inside a reusable block, and the deliberate
absence of invalidation on a form submission.

Mutation-checking these found two tests that passed for the wrong reason.

The Bard test needed the link removed from the fixture to prove it asserts
anything — it does, and it confirms Bard resolves statamic:// entry hrefs during
augmentation, which is what makes v1's hand-rolled reference scanning unnecessary.

The navigation reorder test does not exercise the branch it appears to. Statamic's
two nav-tree events arrive differently: NavTreeSaved hands the invalidator
$tree->structure(), a Nav, while NavTreeDeleted hands it the tree. Removing the
NavTree case from the resolver therefore changed nothing. The tree branch now has
its own assertion.

The submission test is meaningful: swapping the submission save for a form save
fails it, so submissions genuinely reach no invalidation path while form saves do.

Full measure was verified against a real site rather than only in the harness —
files written under public/static, recorded 1:1, and removed once the queue worker
processed the invalidation job.
The README described what gets recorded but never how, which is the part a
developer needs to trust it. It now names the hook points and says why entries hook
getFilteredKeys() rather than get() — count() and pluck() bypass get() entirely.

Adds a "Not covered" section. Statamic dispatches invalidation events for content
only, and only some of it, so four kinds of change clear nothing: assets, blueprints
and fieldsets outside the forms namespace, users, and code. Assets and non-form
blueprints are the notable ones — both already reach the invalidator, they just have
no tag to match, so neither is far from working.

Code deploys are called out separately under Deploying, because that one is easy to
be caught by: templates and translations dispatch nothing at all, so changing a
Blade file leaves the cache serving the old markup until statamic:static:clear runs.

Also trims the local development section and drops a duplicated note about recording
happening only on a cache miss.
Hooking repositories and query builders should make the template's style irrelevant,
but that is a claim worth checking rather than repeating. One test per access path: a
plain PHP query, the Antlers collection, taxonomy and form tags, an augmented field,
and a global off the cascade. All record; the Antlers tags reach the same facades, so
Statamic::tag('collection:articles') and Entry::query() land in the same place.

Also asserts the boundary, so it is pinned rather than assumed: content fetched
outside Statamic records nothing, which is the case CacheTags::add() exists for.

Notes in the README that recording extends the Stache repositories and therefore
assumes the flat-file driver. A site on statamic/eloquent-driver replaces those and
has not been tested — worth stating rather than discovering.
Recording works by subclassing Statamic internals, and several of the members it
relies on are protected rather than public API: getFilteredKeys(), getItems(),
Tags\Nav::structure(), TermRepository::ensureAssociations(),
Variables::newAugmentedInstance(). A minor Statamic release can move any of them.

Lists every class it extends and the member that matters, so a reviewer can see the
blast radius without reading the source, and names the three behavioural assumptions
that would break nothing visibly if they changed: the shape of the $wheres array,
tag classes resolving through the container, and Invalidate driving everything from a
fixed event list.

The actionable part is one line — run the suite after bumping statamic/cms. It is
built around exactly these seams, and three of the bugs found while writing it were
only visible against real behaviour rather than in isolation.

Also notes the release-candidate constraint in the install steps, groups the two
upgrade sections together, and tightens the custom-tag section.
It named 2.0.0-rc.1, which is already superseded, and a version pinned in the README
goes stale every time a tag moves. The tag to test is better communicated with the
tag itself than baked into the install steps.
The upgrade advice said to run `composer install && composer test` without saying
from where, which reads as though it works in the site. It does not: Composer runs
scripts only for the root package, so the addon's `scripts` are ignored when it is a
dependency, and phpunit is a dev dependency of the addon and therefore never
installed in a site. Running it there gives `Command "test" is not defined`.

Also notes that the suite resolves whatever Statamic version Composer offers, so it
tests the release you are moving to, and that CI does the same on every push.
The upgrade advice only worked from a clone of the addon, which is the wrong place:
the moment you want to know is while bumping statamic/cms in a site, and the addon's
suite cannot run there because its dev dependencies are never installed.

cache-invalidation:selftest runs in the site, against the Statamic version that site
has, and checks each seam twice. Structurally, by reflection, that the methods and
properties still exist — that catches a rename. Behaviourally, by recording against
the site's own content and asserting the tags that come back — that catches the
dangerous kind, where a method survives but behaves differently. Every bug found
while building this was of the second kind, and reflection would have missed all of
them.

It is read-only: it queries content and reads the recorder, and writes nothing to the
graph or the cache. Checks needing content a site lacks are skipped rather than
failed, so an empty site does not report a problem it does not have. Exits non-zero
on failure, so it can gate an upgrade in CI.

Verified by regression rather than assumed to work. Against meerdervoort: 23 checks
pass. Reinstating the nav suppression bug fails the nav check, and reinstating the
url-resolution bug fails that check, both naming the leaked tag in the output.

Worth recording that the first mutation attempt did not fail anything, because it
targeted `uri` in the identifying columns while the protection on that path is the
suppression wrapper in TrackingEntryRepository. The uri entry still guards a direct
where('uri') query from a template, but it is not what keeps tree validation out.
Both were missing from the 2.0.0 entry.
Both were listed as bare gaps, which reads as unfinished rather than decided. They
have been reviewed and accepted, so the docs now say what to do instead of only what
does not happen.

The navigation trade-off is stated as the intended workflow: a page save clears where
that page is rendered as content, and the menu is the navigation's concern, so saving
the navigation is how you update it everywhere. Changing a menu means editing the
navigation anyway.

Assets get the practical reason they rarely bite: they are normally changed while
working on a page, and saving that page clears it. Only an asset edited in isolation
— a focal point set from the browser and nothing else — needs the page saved after.
Found by running the selftest on a second site, which is the whole reason it exists.

Facades cache the instance they resolve. Rebinding the repositories in boot is
therefore not enough: if anything touches Statamic\Facades\Entry earlier in the boot
cycle, that facade keeps the original repository for the rest of the request and every
read through it goes unrecorded. On uno, Entry::findByUri() never entered the tracking
repository at all — verified with a probe showing the override was never entered while
the collection tag leaked past the suppression. On meerdervoort the same version
recorded correctly, because nothing there resolves that facade during boot. Same code,
opposite behaviour, decided by boot ordering.

The provider now clears the resolved instance for each contract it rebinds.

Separately, GraphInvalidator no longer reads DefaultInvalidator::$refreshing. That
property only exists in newer 6.x releases, and on versions without it refresh()
duplicates the invalidation logic rather than delegating to invalidate() — so with
background_recache enabled the graph was bypassed entirely and only the saved item's
own URL was refreshed. refresh() is now overridden with our own flag, which behaves
the same on every 6.x and removes a dependency on a member that has already moved
once.

Two fixes to the selftest itself, both of which had made it misreport:

- Structural checks were compound, several members under one label, so a failure said
  "DefaultInvalidator::getItemUrls missing" when the missing member was actually
  $refreshing. One check per member now, and the count went from 23 to 38.
- The url-resolution check called $entry->uri() inside the measured block. uri()
  consults the collection tree, so the check attributed Statamic's own tree validation
  to itself — non-deterministically, since the tree is memoised per process. The uri
  is now resolved before recording starts.

Both sites now pass 38 checks, on Statamic 6.24 and 6.26.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant