Skip to content
MCG Filament CMS Demo

Caching

This page explains the full-page cache that the package puts in front of public CMS pages: what is cached, 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 the package and for the developer writing a plugin whose data appears on a CMS page.

The package caches whole rendered pages, as HTML, for anonymous GET requests only. The CachesCmsPages middleware 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.

Every response the middleware tags carries an X-Cms-Cache header: HIT (served from the cache), MISS (rendered and, on a 200, stored) or BYPASS (rendered and not stored). Check this header first when caching behavior is unexpected.

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. CachesCmsPages returns BYPASS for any request that has a user, before any other check, so nothing rendered for a logged-in user is stored or served from the cache. An editor therefore never sees a stale page, and a draft or signed preview is never stored where a visitor could be served it.
  3. A block on the page is uncacheable (see "What is not cached" below).

The middleware returns no X-Cms-Cache header at all when cms.cache.enabled is false, when the method is not GET, when the query string carries a parameter the key cannot represent, or when the path does not resolve to CMS content. When the header is missing, check the query string first.

The cache is configured in the cache group of config/cms.php. store is the Laravel cache store (null uses the default store). ttl is the lifetime in seconds (null stores the page until it is invalidated).

config/cms.php PHP

'cache' => [
    'enabled' => true,
    'store' => null,                  // null = the default store
    'ttl' => null,                    // null = until invalidated
    'allowed_query_params' => [],
],

The cache key

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

PageCacheKey::forContent() builds the key. 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, so publishing stops the old page being served before any explicit invalidation runs. The invalidation in the table below removes the old entries from the store; it is not what makes the new version appear.

{manifest} is the first eight characters of the Vite manifest hash. 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, so a rebuild invalidates the cache without any further step.

The manifest hash is null while the Vite dev server is running and on a headless install with no Vite manifest. Both fall back to the bare cms:page prefix.

Query strings are treated conservatively, on the assumption that a parameter the cache does not recognize changes the page:

  1. utm_*, fbclid and gclid are removed before the key is built. Adding each tracking value to the key would create an unbounded number of keys.
  2. A parameter listed in cache.allowed_query_params is sorted by name and appended to the key.
  3. Any other parameter makes the request uncacheable, 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. A full flush if the content appears in the navigation.
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.
A page is created, deleted, restored, or saved with a change a page list shows Every page whose Page list or Series nav lists its parent's children, old parent and new.
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 or share image reference that media id. A full flush if the file is used in site settings.
Embedded content (a snippet) is published, unpublished or deleted Every page whose blocks returned that id from contentIdsFrom().

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

  1. Navigation, site settings, menus and tags are read on every page and edited rarely. A page 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.
  2. A page that embeds a snippet records the snippet's id in content_refs, so republishing the snippet forgets exactly those pages. No such record exists for a shared region, because the layout references the layout area, not the page. Every page on that layout is stale, so a layout area change flushes the whole cache.
  3. A replaced media file keeps its id and changes its URL; a focal point change alters the HTML without changing the file. forgetMedia() reads an index built when each page is stored, covering its blocks and its share image. Site settings are not in that index, because a setting such as the site logo is not a property of one page, so a file used in site settings costs a full flush.

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), which both blocks use, calls PageCacheTags::add(PageCacheKey::tagForChildrenOf($parentId)), a tag of the form cms:children:{parentId}. The call runs wherever the block renders: on the page, 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 creates the page or changes published_version_id, parent_id, sort, path or is_homepage, so saving a draft forgets nothing.

The tag collector is request-scoped. A host application that binds its own PageCache implementation does not have forgetChildrenOf(); for that implementation the same changes flush the whole cache. A custom block that reads other pages can use the same mechanism instead of returning false from isCacheable(): call PageCacheTags::add() with a tag during render, and forget that tag when the data it read changes.

What is not cached

VersionCacheability walks every block in the version, including blocks inside containers, and refuses to store the page if any block returns false from isCacheable() or true from embedsForm($data). A single false prevents the page from being stored. The blocks the package ships that return false from isCacheable() are breadcrumbs, form, navigation, related, search and tag_list. embedsForm() exists for a block that can hold a form without being the form block: Subscribe returns true only when a form is selected.

The walk then follows contentIdsFrom() into embedded content (snippets and layout areas) and applies the same check to its blocks, so a form in a footer snippet makes every page using that footer uncacheable. With multilingual content enabled, it checks the snippet's translation in the language being served, because that is the version that renders.

Link targets are not followed. contentIdsFrom() also reports the pages a block links to, so that republishing one refreshes the link, but a linked page's blocks never render on the page that links to it. Until 0.78.13 link targets were followed, and a page whose buttons linked to a page with a search box was never cached.

The check runs after the cache lookup and before a page is stored, so a cache hit is served without walking the block tree. An entry exists only if the check passed when it was written, and the result changes only on a publish, which forgets the entry. Already-visited content is skipped: snippets cannot nest today, but a cycle would hang a page request rather than fail a single edit.

An unknown block type is treated as cacheable. It renders nothing, so it cannot make the page vary, and treating it as uncacheable would let a removed plugin disable caching across the whole site without any error.

Preview responses (Cache-Control: no-store, private) and any response that is not a 200 are never stored.

Invalidating manually

PHP

use Mcg\FilamentCms\Contracts\PageCache;

app(PageCache::class)->forgetContent($content);   // one page, and every page that embeds it
app(PageCache::class)->forgetPath('/about');      // the content at that path
app(PageCache::class)->forgetMedia($mediaId);     // every page using that media id
app(PageCache::class)->flush();                   // every cached CMS page

Shell

php artisan cms:cache-clear
Flushes the whole cache and prints "CMS page cache cleared."