Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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/projectenand/vacaturesinvalidated 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'sauthor,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
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.customEntryUrls().nocacheregions 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 fromconfig/cache_invalidation.phpwhen convenient;cache-invalidation:doctorlists any still present.A
statamic.static_caching.invalidation.classpinned atContentDependencyInvalidatorcan 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.DefaultInvalidatorflips a protected flag before delegating toinvalidate(), which 1.x overrode without checking, sostatic_caching.background_recachehard-purged instead of refreshing.pagebuilderfield now invalidate.New
cache-invalidation:why,:affected,:stats,:clearand:doctor.affectedanswers "what clears if I save this?" before saving — the question the 1.x design could not be asked.doctorexits 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.Verification
82 tests, 149 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:
findByUri(), an unscopedwhere('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.config:cachebroke the addon entirely.mergeConfigFromis 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.StatusQueryBuilderwhose 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
getItemUrlseach fails exactly the tests that should catch it.Not covered