Cache Invalidation
Who is this for? Developers adding custom models that should invalidate frontend caches when changed (e.g., custom news articles, product data, or settings).
TL;DR: Register your custom model with
CacheInvalidationRegistry::registerDependency()to declare “when this model changes, flush these cache keys.” Resolve the registry from the container; it is not a static utility.
When to use this
Section titled “When to use this”Cache invalidation via CacheInvalidationRegistry is for registering relationships between custom models and cache keys. Capell ships with built-in registrations for Site, Language, Page, Navigation, and SiteDomain, plus special-case handling for Translation records and site-logo Media (a logo change flushes the frontend tag). If you:
- Add a custom model (e.g.,
BlogPost,TeamMember) - Want frontend caches to bust automatically when that model is saved/deleted
- Don’t want to manually call
Cache::forget()in observers
…then CacheInvalidationRegistry is your tool.
This is not a replacement for fragment caching. Fragment caching uses surrogate keys inside Blade templates; this registry uses model lifecycle events to decide which cache keys to flush. They compose: a model observer calls the registry to flush keys, then flushes fragments tagged with surrogate keys (if any).
How It’s Wired
Section titled “How It’s Wired”The registry is a container-managed service. Register dependencies during service-provider boot, then call invalidateForModel() from observers or listeners that handle model changes. Observers in packages/core/src/Observers/ (e.g., SiteObserver, PageObserver, LanguageObserver) trigger events (PageSaved, PageDeleted) that are consumed by listeners in packages/frontend/src/Listeners/.
Current flow (built-in models):
- Model observer (e.g.,
PageObserver::saved()) callsCapellCoreHelper::flushCache()with enum-based keys, or fires an event likePageSaved - Frontend listeners like
PurgeCdnCacheOnPageChangeListenerhandle the event and call custom logic (e.g.,PurgeCdnCacheByPageAction) - For custom models, wire the observer to call
resolve(CacheInvalidationRegistry::class)->invalidateForModel(ModelClass::class)
File locations:
- Registry:
packages/frontend/src/Support/Cache/CacheInvalidationRegistry.php - Built-in registrations: inside the registry’s
$modelDependenciesproperty
Public API
Section titled “Public API”| Method | Signature | Purpose |
|---|---|---|
registerDependency | registerDependency(string $modelClass, string|array $cachePatterns): void | Register one or more cache patterns for a model class. Patterns are merged with any existing registrations for that class. Pass a string or array of strings. |
invalidateForModel | invalidateForModel(string $modelClass): void | Look up patterns for a model class and flush the appropriate cache keys. Called from model observers. |
What “patterns” means
Section titled “What “patterns” means”Patterns are cache keys or tags, passed to Laravel’s cache driver:
-
Exact-key patterns (no
*wildcard): forwarded toCache::forget($pattern), matching exact cache keys- Example:
'sites','languages','page-error-404'
- Example:
-
Wildcard patterns (contain
*): trigger a full tag flush instead- Example:
'page-*','site-related-*' - When any wildcard pattern is detected, the registry calls
Cache::tags(['capell-frontend'])->flush()and returns early - This is a safety mechanism: rather than implementing wildcard matching across cache backends, the registry opts for full flush
- Example:
Why the conservative approach? Different cache drivers (Redis, Memcached, file) handle wildcards differently. A full tag flush is predictable and avoids stale data.
Example — registering a custom model
Section titled “Example — registering a custom model”Create a custom model observer for your BlogPost model:
<?php
declare(strict_types=1);
namespace App\Observers;
use App\Models\BlogPost;use Capell\Frontend\Support\Cache\CacheInvalidationRegistry;
final class BlogPostObserver{ public function saved(BlogPost $blogPost): void { resolve(CacheInvalidationRegistry::class)->invalidateForModel(BlogPost::class); }
public function deleted(BlogPost $blogPost): void { resolve(CacheInvalidationRegistry::class)->invalidateForModel(BlogPost::class); }}Register the dependency in your app’s service provider:
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Models\BlogPost;use Capell\Frontend\Support\Cache\CacheInvalidationRegistry;use Illuminate\Support\ServiceProvider;
final class AppServiceProvider extends ServiceProvider{ public function boot(CacheInvalidationRegistry $cacheInvalidation): void { $cacheInvalidation->registerDependency( modelClass: BlogPost::class, cachePatterns: ['blog-posts', 'blog-posts:*', 'homepage-featured'], ); }}Now, when a BlogPost is saved or deleted, the observer calls invalidateForModel(), which flushes 'blog-posts' and 'homepage-featured' keys exactly, and (because 'blog-posts:*' contains *) triggers a full Cache::tags('capell-frontend')->flush().
Companion APIs
Section titled “Companion APIs”Fragment caching vs. invalidation registry:
-
FragmentCache::invalidateBySurrogateKey(string $surrogateKey)— Used inside Blade templates to tag fragments. Called manually from event listeners when you want surgical invalidation of specific fragments.- Example:
@cache('featured-posts', 3600, ['featured-posts:list']); later,FragmentCache::invalidateBySurrogateKey('featured-posts:list')clears just that fragment.
- Example:
-
CacheInvalidationRegistry::registerDependency()+invalidateForModel()— Used for model-driven invalidation. Declares “when MODEL X changes, flush THESE cache keys.” Typically called from model observers, not directly in templates.
The registry is declaration-time (in service providers); fragment invalidation is event-time (in observers/listeners). Use both:
// Service provider: declare the registry$registry = resolve(CacheInvalidationRegistry::class);
$registry->registerDependency( BlogPost::class, ['blog-posts', 'blog-featured-*'],);
// Observer: invalidate both registry keys and fragment surrogatespublic function saved(BlogPost $blogPost): void { resolve(CacheInvalidationRegistry::class)->invalidateForModel(BlogPost::class); FragmentCache::invalidateBySurrogateKey('blog-featured');}Gotchas
Section titled “Gotchas”-
Register in
boot(), notregister()— service providers should register dependencies inboot()rather thanregister(), ensuring other packages have already initialized. Many CMS extensions register their own cache patterns and depend on a predictable boot order. -
Model class must be fully qualified — pass the full namespace:
BlogPost::classor'App\Models\BlogPost', not just'BlogPost'. -
Multiple registrations merge — calling
registerDependency()twice for the same model class merges the pattern arrays:$registry = resolve(CacheInvalidationRegistry::class);$registry->registerDependency(Post::class, ['posts']);$registry->registerDependency(Post::class, ['featured']); // Now Post maps to ['posts', 'featured'] -
Wildcard triggers full flush — if even one pattern contains
*, the entirecapell-frontendtag is flushed. Wildcard patterns are an all-or-nothing signal. Use sparingly if your app has many cache keys. -
Observer must be wired — the registry only works if your model observer is registered. Register it in a service provider:
BlogPost::observe(BlogPostObserver::class); -
No automatic wiring — unlike Capell’s built-in models (registered in core observers), custom models are not automatically observed. You must wire the observer yourself.
Graph-based dependent-page invalidation
Section titled “Graph-based dependent-page invalidation”Which pages are reached through recorded dependencies? A Page with no recorded dependency path is outside the graph-derived subset, not necessarily outside the final invalidation plan. CacheInvalidationRegistry also merges registered class rules, and site-logo Media uses a broader Site/Page rule path. The declared plan does not promise automatic rebuilds of external consumers, search indexes or CDNs. This is a source-level explanation, not a cache execution test.
Pattern registration answers “which cache keys does this model flush.” A second, finer path answers “which pages must re-render because this model changed”: content-graph edges. Extractors (ContentGraphExtractor implementations in packages/core) record edges such as Page —UsesLayout→ Layout or Page —FoundOnPage→ Page; when a model changes, CacheInvalidationRegistry walks the stored edges from target to source (dependentPages()) and plans page-level invalidation for every dependent page it reaches.
Core registers FoundOnPage edges for pages embedded in another page’s composed block content — widget page references and curated listings. The shipped PageEmbedContentGraphExtractor walks every translation’s block content and treats these data keys as embedded page references:
| Key | Shape | Notes |
|---|---|---|
page_id | single key | PageSelect state in widget and section data |
pageable_id | single key | Skipped when a sibling pageable_type resolves to a non-Page model |
page_ids | list of keys | Curated listings |
pages | list of keys | Curated listings |
The __capell envelope is system state and is never scanned. Self-references and non-numeric values are ignored, traversal is bounded (depth 64, 10,000 nodes), and the edges register with Weak strength so embeds inform invalidation and impact previews without blocking deletes.
If your widget stores embedded pages under different keys, register your own extractor — tag it with ContentGraphRegistry::TAG or declare a content-graph manifest contribution — and emit FoundOnPage edges with the embedding page as the source and the embedded page as the target. The dependent-page walk follows edges from target to source, so that direction is what makes an embedded-page edit reach the embedding page. See the extension point API reference.
Related
Section titled “Related”- Fragment caching — cache expensive Blade partials with surrogate key invalidation.
- ETag and conditional responses — validate client caches without recomputing.
- Critical asset optimization — defer non-critical CSS and JS for faster first paint.
- Performance index