Skip to content
MCG Filament CMS Demo

Caching

This document explains the full-page cache that mcg/filament-cms puts in front of public CMS pages. It covers what the package caches, how a cache key is composed, which actions invalidate which entries, which blocks keep a page out of the cache, and how to invalidate an entry from your own code. It is written for the developer who deploys a site built on this package and for the developer writing a plugin whose data appears on a CMS page.

What is cached

The package caches whole rendered pages, as HTML, for anonymous GET requests only. The CachesCmsPages middleware (src/Http/Middleware/CachesCmsPages.php) reads and writes the cache. It does not cache a request that uses any other HTTP method, a request from a logged-in user, or a response with any status other than 200.

The cache is configured in the cache group of config/cms.php:

PHP

'cache' => [
    'enabled' => true,
    'store' => null,                  // null = the default store
    'ttl' => null,                    // null = until invalidated
    'allowed_query_params' => [],
],
Key Default Behavior
enabled true When false, the middleware passes every request through without reading, writing or tagging it.
store null The Laravel cache store that holds the pages. null uses the application's default store.
ttl null Lifetime of a cached page, in seconds. null stores the page with forever(), so it stays until it is invalidated.
allowedqueryparams [] Query parameters that are added to the cache key. See Query strings.

The X-Cms-Cache header

Every response the middleware tags carries an X-Cms-Cache header with one of three values. Check this header first when caching behavior is unexpected.

Value Meaning
HIT The page was served from the cache.
MISS The page was rendered and, if the status was 200, stored.
BYPASS The page was rendered and was not stored.

BYPASS is returned in three cases:

  1. The Vite dev server is running. While it runs, @vite emits tags that point at localhost:5173. A cached copy of that HTML would outlive the dev server: later visitors would wait for those requests to time out and then receive an unstyled page with no indication of the cause.
  2. The request is authenticated. See Authenticated requests.
  3. A block on the page is uncacheable. See What is never cached.

Requests that carry no header

The middleware returns some responses without an X-Cms-Cache header at all. This happens when:

  1. cms.cache.enabled is false.
  2. The request method is not GET.
  3. The query string carries a parameter the cache key cannot represent (see Query strings).
  4. The path does not resolve to CMS content, or resolves to an external redirect target.

When a response has no X-Cms-Cache header, check the query string first.

Authenticated requests

CachesCmsPages returns BYPASS for any request that has a user, before it performs any other check. Nothing rendered for a logged-in user is written to the cache, and nothing in the cache is served to a logged-in user.

This one rule has two effects:

  1. An editor never sees a stale page.
  2. Draft view cannot leak. A page rendered from an unpublished draft, or opened through a signed preview link, is only ever rendered for a request that bypasses the shared cache, so no visitor can be served it.

The rule existed before the draft feature. The draft feature was built on top of it.

The cache key

A cached page is stored under this key:

Plain text

cms:page:{manifest}:{site}:{locale}:{content_id}:{published_version_id}[:{query}]

PageCacheKey::forContent() (src/Support/PageCacheKey.php) builds it. A segment whose value is null, such as site on a single-site install, is written as -.

The published version id is part of the key. After a publish, the request builds a key with the new version id and does not find the previous entry. Publishing therefore stops the old page being served before any explicit invalidation runs. The invalidation described in What invalidates what removes the old entries from the store; it is not what makes the new version appear.

The Vite manifest hash is part of the prefix, truncated to its first eight characters. A cached page holds the whole document, including its <link href="…/app-BqS94cNi.css"> tag, and npm run build writes a stylesheet with a new filename and deletes the old file. Without the hash in the key, every page cached before a deploy would reference a stylesheet that no longer exists, and would render unstyled until someone ran cms:cache-clear. Nothing about that symptom points to the cache. With the hash in the key, a rebuild produces a different key prefix: the old entries are never read again and expire on their own. A rebuild therefore invalidates the cache without any further step.

The manifest hash is null in two situations, and both fall back to the bare cms:page prefix:

  1. The Vite dev server is running.
  2. There is no Vite manifest, as on a headless install that serves no HTML.

Query strings

The cache treats query strings conservatively: it assumes that a parameter it does not recognize changes the page.

Parameter Treatment
utm, fbclid, gclid Removed before the key is built, so they do not change it. These are marketing tracking parameters, and adding each value to the key would create an unbounded number of keys.
A parameter listed in cache.allowedqueryparams Sorted by name and appended to the key.
Any other parameter The request is not cached and its response carries no X-Cms-Cache header.

What invalidates what

Event Effect
A page is published, unpublished or deleted That page's entries are forgotten. If the content appears in the navigation, the whole cache is flushed instead.
A page's path changes The entries for the old path, the new path and the page itself are forgotten.
A layout area is published, unpublished or deleted Full flush.
Site settings are saved Full flush.
A menu or menu item is saved or deleted Full flush.
A tag is attached to or detached from content Full flush.
A media file is replaced, or its focal point is changed Every page whose blocks, share image or content type fields (attributes) reference that media id is forgotten. If the file is used in site settings, as a menu item image, as a page's Navigation image (navimagemediaid) or by a row in one of the application's own tables, the whole cache is flushed instead.
Embedded content (a snippet) is published, unpublished or deleted Every page whose blocks returned that content id from contentIdsFrom() is forgotten.
A page is created, deleted, restored, or saved with a change to what a page list shows Every cached page that lists that page's parent's children is forgotten. See Pages that list other pages.

The publish, unpublish, delete, path and media rows are handled by InvalidatePageCache (src/Listeners/InvalidatePageCache.php). Site settings, menus and child-page lists are handled in FilamentCmsServiceProvider, and tags in the ContentTag model.

Reasons for each full flush

Each full flush in the table has the same cause: the change affects data that is rendered on every page, and the package keeps no index that names which cached pages contain it.

  1. Navigation, site settings, menus and tags. These are read on every page and edited rarely. A page that appears in the navigation is rendered into every other page, so publishing it makes pages stale that did not themselves change. A tag appears on the page it labels, on its own archive and on any page that lists tags, which can be every page.
  2. Layout areas. A page that embeds a snippet records the snippet's id in the version's content_refs column, so republishing the snippet forgets exactly the pages that hold it. No equivalent record exists for a shared region, because the page never references the layout area; the layout does. Every page on that layout is stale, and there is no narrower way to identify them. See Layouts, regions and page width.
  3. **Media used in site settings, on a menu item, as a navigation image, or by an application table.** A replaced media file keeps its id and changes its URL, so every page holding that id serves a broken src until it is re-rendered. A focal point change alters the rendered HTML in the same way without changing the file. forgetMedia() reads an index built when each page is stored, which covers the page's blocks, its share image and the media fields in its attributes. Four uses render on pages the index does not list: the site settings columns such as the logo, menu item images (cms_menu_items.image_media_id), a page's Navigation image (attributes.nav_image_media_id), which DerivedMenuItems::navImageId() shows in the derived menu on every page, and a row in one of the application's own tables (kind app_record). An application row reaches pages through a layout, a block or a route resolver, and only a block can declare the ids it shows to the index. MediaCachePurge::for() therefore flushes the whole cache for a file used in any of the four. After a full flush, every cached page is rendered again on its next request.

The share image and the attribute media ids are indexed when the page is stored (in LaravelPageCache::put(), which calls MediaReferences::attributeMediaIds() for the attributes) rather than by adding them to the version's media_refs column. Versions are immutable, so a change to how media_refs is computed would only apply to versions created after the change, and every existing version would stay unindexed.

Version 0.81.3 added the attribute media ids to this index. With the default cms.cache.ttl of null, put() stores a page with forever(), so a page cached before 0.81.3 is never stored again and is never indexed under those ids. After updating to 0.81.3, run php artisan cms:cache-clear in a terminal in the site's root directory.

Pages that list other pages

A Page list or Series nav block renders pages other than the one it sits on: the published children of a parent page. A child published for the first time has no reference on the listing page, so neither the listing page's own tag nor its content_refs would cause its entry to be forgotten. Until 0.78.15 both blocks returned false from isCacheable() for that reason, which kept every page carrying one out of the cache.

Both blocks are now cacheable, through a tag collected during rendering:

  1. ChildPages::for($parentId) (src/Support/ChildPages.php), which both blocks use, calls PageCacheTags::add(PageCacheKey::tagForChildrenOf($parentId)). The tag has the form cms:children:{parentId}. This call runs wherever the block renders: on the page itself, in a snippet or in a shared region.
  2. LaravelPageCache::put() indexes the cached page under every tag collected during that render, in addition to its usual tags.
  3. When a page is saved, deleted or restored, FilamentCmsServiceProvider calls LaravelPageCache::forgetChildrenOf() with the page's parent before and after the change. A save triggers this only when it changes published_version_id, parent_id, sort, path or is_homepage, or creates the page. Saving a draft changes none of these columns and forgets nothing. Moving a page from one parent to another forgets the lists of both parents.

The tag collector is request-scoped, so tags collected in one request are never added to another page's entry. A host application that binds its own PageCache implementation does not have forgetChildrenOf(). For that implementation, the same changes flush the whole cache instead of leaving a list stale.

A custom block that reads other pages can use the same mechanism instead of returning false from isCacheable():

  1. During render, call PageCacheTags::add() with a tag that identifies the data the block read.
  2. When that data changes, forget the tag.

What is never cached

VersionCacheability::allows() (src/Support/VersionCacheability.php) decides whether a rendered version may be stored. It walks every block in the version, including blocks inside containers such as tabs, columns and sections, and returns false if any block definition does either of the following:

  1. Returns false from isCacheable().
  2. Returns true from embedsForm($data) for that block's stored data.

A single false prevents the whole page from being stored. A form inside a tab makes the page uncacheable in the same way as a form at the top level of the page.

The walk then follows contentIdsFrom() into embedded content and applies the same check to its blocks, so a form in a footer snippet makes every page using that footer uncacheable. Three rules limit what is followed:

  1. Only content that is not routable is followed: snippets and layout areas. contentIdsFrom() also reports link targets, so that republishing a linked page refreshes the link. A linked page is rendered on its own URL and none of it renders on the linking page, so it is skipped. Before 0.78.13 link targets were followed, and a page whose cards or buttons linked to a page with a Search block was never cached.
  2. When multilingual content is enabled, the check reads the embedded content's translation in the language being served, because that is the version that renders.
  3. Content already visited in the walk is skipped. Snippets cannot nest today, but a cycle would hang a page request rather than fail a single edit, so the walk guards against it.

The blocks the package ships that return false from isCacheable() are breadcrumbs, form, navigation, related, search and tag_list.

Block::isCacheable() is enforced, not advisory. The middleware calls the check on every request that would store a page, and a block returning false is the mechanism by which a page is kept out of the cache.

When the check runs

The check runs after the cache lookup, not before it. A request that finds a stored entry is served without walking the block tree or looking up embedded snippets. Serving the entry without the check is safe for two reasons:

  1. An entry is written only after the check has passed for that version.
  2. The check's result changes only when a version is published. Publishing the page forgets its entries by the page's own tag, and publishing an embedded snippet forgets them by the snippet's tag, which LaravelPageCache::put() indexes from the page's content_refs.

On a miss, the middleware hands the content it resolved to build the cache key to CmsFrontendController on the request, through Resolution::remember() and Resolution::recall(), so the page is not resolved a second time.

Forms

A form is the reason the check exists. The form block's markup carries a Livewire snapshot and a signed submission timestamp, and both are specific to one request. Stored in the shared cache, they would be served to a second visitor, and the submission would fail with errors that look like a framework bug rather than a caching decision.

Block::embedsForm(array $data) is the second signal, for a block that can hold a form without being the form block. isCacheable() takes no arguments, so it cannot distinguish a Subscribe block that carries a form from one that carries only a feed link. embedsForm() receives the block's stored data and answers per block instance: Subscribe::embedsForm() returns true only when a form is selected, so a page whose Subscribe block shows only a feed link stays cacheable. The default implementation returns false, so no other block's behavior changes.

Unknown block types

An unknown block type, such as one left in a version after its plugin was removed, is treated as cacheable. It renders nothing, so it cannot make the page vary. Treating it as uncacheable would let a removed plugin disable caching across the whole site without any error.

Other responses that are never stored

  1. Preview responses. CmsPreviewController returns Cache-Control: no-store, private and X-Robots-Tag: noindex, nofollow.
  2. Any response that is not a 200. The middleware writes only on a 200 status, so a 404 or a redirect is rendered fresh on every request.

A block that renders anything visitor-specific, such as a Livewire component with per-user state or any output that reads the session, must return false from isCacheable(). See Block reference.

Invalidating manually

The package has no automatic invalidation for data it does not own. If a product name from your own database appears on a cached CMS page and the product is renamed, your code must invalidate that page.

PHP

use Mcg\FilamentCms\Contracts\PageCache;

app(PageCache::class)->forgetContent($content);   // one page
app(PageCache::class)->forgetPath('/about');      // by path
app(PageCache::class)->forgetMedia($mediaId);     // every page using that asset
app(PageCache::class)->flush();                   // everything
Method Effect
forgetContent(Content $content) Forgets every cached entry for that page, and every page that embeds it.
forgetPath(string $path, ?string $siteId = null) Looks up the content at that path and forgets it as forgetContent() does. Does nothing if no content has the path.
forgetMedia(int $mediaId) Forgets every page whose blocks, share image or content type fields (attributes) reference that media id.
flush() Forgets every cached CMS page.

Use forgetContent() when the affected pages can be named. Use flush() only when they cannot, as the built-in listener does for navigation changes.

To flush the cache from the command line or a deploy script, run this in a terminal at the site's root:

Shell

php artisan cms:cache-clear

The command prints CMS page cache cleared.

flush() catches QueryException and returns without error. With the database cache driver, the store's tables are created by a migration, so cms:cache-clear on a fresh environment (a deploy script or a build step) queries a table that does not exist yet. A site settings or menu save during a seeder that runs mid-migration does the same. Nothing can have been cached in that state, so there is nothing to purge, and the command succeeds. Only query errors are caught; a misconfigured store still throws.

Binding your own implementation

PageCache (src/Contracts/PageCache.php) is a contract. To route every invalidation described in this document through your own implementation, for example one that also purges a CDN, bind it to PageCache in a service provider registered after the CMS. See Integrating another plugin for the other plugin extension points.