Skip to content
MCG Filament CMS Demo

Architecture

This document explains how the CMS is constructed. It covers how a page is stored in the database, how an HTTP request becomes rendered HTML, where each file lives in the package and in the application, how the registries find the block and content type classes an application writes, and how the two tree-reading blocks build navigation.

It is written for a developer who has installed the package and intends to extend it. Every behavior described in this document is part of the package's supported interface.

---

The data model

The data model has six entities: content, version, block, content type, layout and skin. They are defined below in the order a request encounters them.

Entity What it is Where it is stored or defined
Content A page's identity and position A row in the content table
Version A page's editable content at one point in time A row in the contentversions table
Block One entry in a version's block list {type, region, data} inside the version's block list
Content type A kind of content, such as a page or a snippet A PHP class registered with ContentTypeRegistry
Layout The page chrome and its regions An anonymous Blade component in the cms-layout:: namespace
Skin A set of design token values --cms- custom properties scoped to a [data-cms-skin] attribute

Content is a row in the content table. It holds identity and position: type, slug, parent_id, path, sort, is_homepage, the locale and site scopes, the pointers to the draft version and the published version, and the scheduling timestamps. It holds none of the editable content, with the one exception described in The content.title column.

Version is a row in the content_versions table. It holds the attributes, the block list, the SEO fields, the media references and the flattened search_text.

Versions are immutable. Every save appends a new draft version. Publishing sets content.published_version_id to one of the existing versions. Because a stored version is never rewritten, a change to the shape of a block's stored data is migrated when the data is read, in the block's cast() method, rather than by a database migration. See Block reference.

Block is one entry in a version's block list, stored as {type, region, data}. A block definition is either a PHP class plus a Blade view, or a single Livewire Blade file. The package generates, compiles and registers nothing else for a block.

The block list is a tree, not a flat list. A container block (section, tabs, columns or accordion) stores its child blocks inside its own data and declares where they are stored through Block::childBlockPaths(). Every operation that traverses blocks does so through Support\BlockTree:

  1. validation
  2. collection of media references and content references
  3. extraction of search text
  4. the page cacheability check
  5. the decision to load Alpine
  6. the MCP block tools

For a block that declares no children, BlockTree visits exactly the entries a flat loop would visit. Container blocks were therefore added without changing the behavior of any block that existed before them.

Content type describes a kind of content: its fields, whether it nests, how its URL is derived, and which CMS features apply to it. One Filament resource serves every content type. Adding a content type adds a navigation item and a URL space without adding a resource class. See Content types.

Layout is an anonymous Blade component in the cms-layout:: namespace. It provides the page chrome (<html>, the head, the header and the footer) and one or more regions that blocks are placed in. See Layouts and regions.

Skin is a set of reassignments of --cms-* custom properties, scoped to a [data-cms-skin] attribute. A skin contains no templates of its own. See Design system.

The built-in content types

FilamentCmsServiceProvider::registerContentTypes() registers three built-in content types:

Key What it is Registered
page A routable page with a path, a position in the page tree, and a layout Always
snippet A reusable block list with no URL, embedded into pages by the snippet block When cms.snippets.enabled is true, which is the default
layoutarea The blocks belonging to one shared region of one layout When cms.layoutareas.enabled is true, which is the default

The layout_area type stores shared regions; editors do not author it as a page. Because a shared region is stored as ordinary content with no URL, its blocks receive immutable versions, the draft-and-publish workflow, version history, diffing and the MCP tools. None of those features had to be implemented a second time for regions.

An application registers its own content types through cms.content_types.register or cms.content_types.discover. Registration runs in this order: the built-in types, then the classes listed in register, then the classes found by discover. A later registration of the same key replaces an earlier one, so an application replaces a built-in type by registering a class with the same key.

The content.title column

The content row has one column that copies editable content: content.title. VersionManager::save() writes it with the title of the version it has saved, which is the newest draft.

The title belongs to the version. Restoring a version restores the title the page had when that version was saved. Without a copy on the content row, however, the title is not a column, and searching or sorting by it requires a JSON predicate whose syntax differs across the three supported databases. Before the column existed, the panel's content table searched and sorted its Title column by slug. A page titled "Pitfalls" at /docs/known-traps could not be found by typing its title, and content was excluded from global search.

The mirrored column works on all three databases, can be indexed, and is written in one place: VersionManager::save(), the only method that creates a new draft.

The column is derived and is never the source of truth. Read the title through Content::title(). It returns the column when it is filled, and otherwise the title of the draft version, then the title of the published version, then the slug. A stale column can therefore change the order of a sorted list, but it cannot change what a page displays.

---

The request path

Plain text

GET /about
  └─ routes/cms.php catch-all: Route::get('/{path?}')->where('path', '.*')
     registered inside app()->booted(), so it is matched after every
     application and plugin route
       β”œβ”€ DetectsCmsLocale     removes a locale prefix and sets the app locale
       β”œβ”€ HandlesCmsRedirects  matches the path against stored redirects
       β”œβ”€ CachesCmsPages       serves a stored render, or stores this one
       └─ RendersCmsNotFound   innermost, so it wraps only the controller
            └─ CmsFrontendController
                 └─ ResolverChain: RouteResolvers in ascending priority order
                      └─ CmsContentResolver (priority 100) finds Content by
                         `path`, then uses the draft where one is newer than
                         the published version
                           └─ renders $type->view() ?? cms::content.default
                                └─ <x-dynamic-component :component="$layout">
                                     └─ <x-cms::region name="main" />
                                          └─ <x-cms::block> per stored block
                                               └─ cast() β†’ withView()
                                                  β†’ @include($block->view())

Four parts of this path are design decisions. Each is described below.

The catch-all route is registered inside app()->booted()

The catch-all route /{path?} is registered inside app()->booted(), so it is matched after every route that the application and every other plugin declared. If the CMS registered /{path?} during boot(), it would take precedence over routes the host application owns, and a host application cannot be expected to reorder its service providers around a plugin.

The consequence is that any route the application declares is matched before the CMS. This includes Laravel's default Route::get('/') in routes/web.php, which is why a page marked as the homepage can still show Laravel's welcome screen at /. cms:install offers to remove that route; see Installation.

Resolvers form a chain

ResolverChain holds RouteResolver implementations sorted by priority() in ascending order, and returns the first non-null Resolution. CmsContentResolver has priority 100. A host application or another plugin claims a URL space ahead of stored content by registering a resolver with a lower number, or handles requests the CMS did not match by registering a resolver with a higher number. Neither resolver needs to reference the other. See Integrating another plugin.

RendersCmsNotFound runs inside CachesCmsPages

RendersCmsNotFound is the innermost middleware, inside CachesCmsPages. This order prevents a 404 from being cached. CachesCmsPages stores only responses with status 200, so the styled not-found page that RendersCmsNotFound produces inside it is returned to the visitor and not stored.

The controller shares view data

CmsFrontendController passes cmsBlocks, cmsContent, cmsVersion, cmsAttributes and cmsLayout to the views through View::share(), not through the view's data array. A layout is an anonymous Blade component, and <x-cms::region> is used inside it. If the variables were passed as data, every layout, including hand-written layouts, would have to declare and forward props it does not use. With View::share(), a custom layout contains only the markup its author writes.

The controller also calls Headings::reset() before rendering. Heading anchors are numbered across a whole page. Without the reset, a long-running worker process serving a second request would continue the numbering from the first, and the only "Overview" heading on the second page would receive the anchor overview-2. CmsPreviewController calls Headings::reset() for the same reason.

---

Where everything lives

Two directory trees are involved. The package's tree is read but not edited. The application's tree overrides the package's by file location; no configuration is required.

In the package (vendor/mcg/filament-cms):

Plain text

src/Blocks/{Studly}.php                     the built-in block classes
src/ContentTypes/{Page,Snippet,LayoutArea}.php   the built-in content types
resources/views/blocks/{key}.blade.php      block markup, namespaced
                                            cms::blocks.{key}
resources/examples/{key}.json               sample content the panel gallery
                                            renders
resources/views/layouts/{name}.blade.php    the six layouts, cms-layout::{name},
                                            and what --tag=cms-views publishes
resources/views/components/*.blade.php      head, seo, region, block,
                                            navigation, header, footer, and
                                            the other cms:: components
resources/css/cms.css                       tokens plus every component rule
resources/css/skins/{skin}.css              token reassignments per skin
stubs/*.stub                                what the cms:make-* generators write
config/cms.php                              what --tag=cms-config publishes

In the application:

Plain text

app/Cms/Blocks/**/*.php                     the application's blocks,
                                            discovered recursively
app/Cms/ContentTypes/**/*.php               the application's content types,
                                            discovered recursively
resources/views/livewire/blocks/*.blade.php single-file Livewire blocks
resources/views/cms/blocks/{kebab}.blade.php    where cms:make-block writes
                                                views; requires a view()
                                                override
resources/views/cms/skins/{skin}.blade.php  a skin's runtime partial, where
                                            cms:make-skin writes it
resources/cms/examples/{key}.json           the application's gallery examples,
                                            read before the package's
resources/views/vendor/cms/blocks/{key}.blade.php   overrides a package block's
                                                    markup
resources/views/vendor/cms/layouts/{name}.blade.php the application's layouts,
                                                    and overrides of the
                                                    package's, as
                                                    cms-layout::{name}
resources/css/skins/{skin}.css              the application's skin stylesheets
resources/css/app.css                       imports cms.css and any skins
config/cms.php                              the published config

resources/views/cms/ and resources/views/vendor/cms/

The application has two directories with similar names that behave differently. Editing a view in the wrong one is a common cause of changes that never appear on the page, with no error.

Directory What it is How it is resolved
resources/views/cms/ An ordinary application view directory Not resolved automatically. A block whose view is here must override view() to return its name
resources/views/vendor/cms/ Laravel's vendor-override path for the cms:: view namespace Resolved before the package's own views, automatically

resources/views/cms/ is where cms:make-block writes a block's view and where cms:make-skin writes a skin partial. A block whose template is in this directory must override view() to point at it. Without the override, Block::view() returns cms::blocks.{key}, which resolves inside the package, and the block throws View [cms::blocks.pricing_table] not found the first time an editor places it on a page.

resources/views/vendor/cms/ is resolved ahead of the package automatically. Creating a file there is the entire override; no configuration entry or publish step is required. php artisan vendor:publish --tag=cms-block-views exists only to copy the package's markup into this directory as a starting point.

Layouts are an exception to the vendor-override rule. cms-layout:: is an anonymous component namespace, and Laravel does not give anonymous component namespaces the automatic vendor precedence that view namespaces receive. The service provider therefore registers resources/views/vendor/cms/layouts explicitly with Blade::anonymousComponentPath(), and registers it before the package's own layouts path, because Blade uses the first match. Without that registration, the layout stub that cms:install publishes to that directory would never be used.

Skin partials are resolved in the opposite order to block views. Theme::skinPartial() checks cms.skins.{skin} (the application's resources/views/cms/skins/ directory) before cms::skins.{skin}. The package ships no skin partials, so every skin partial in use is the application's and is stored in resources/views/cms/skins/.

---

Registration and discovery

A class in app/Cms/Blocks is registered as a block as soon as the file exists. There is no manifest and no cache to rebuild.

PHP

// config/cms.php (the default)
'blocks' => [
    'discover' => ['App\\Cms\\Blocks' => null],
    'register' => [],
],

discover maps a namespace to a directory. The value null means the directory is derived from the namespace: the service provider removes the leading App\ and treats the remainder as a path under app/, so App\Cms\Blocks resolves to app/Cms/Blocks. Give the directory explicitly when the namespace does not follow that pattern:

PHP

'discover' => [
    'App\\Cms\\Blocks' => null,                 // app/Cms/Blocks
    'Acme\\Marketing\\Blocks' => base_path('modules/marketing/Blocks'),
],

register takes explicit class names, for blocks stored where discovery does not look:

PHP

'register' => [
    App\Cms\Blocks\PricingTable::class,
    Acme\Shop\Cms\ProductGrid::class,
],

Discovery rules

BlockRegistry::discover() scans with these rules:

Rule Behavior
The scan is recursive app/Cms/Blocks/Marketing/PricingTable.php is found. Its class must be App\Cms\Blocks\Marketing\PricingTable, because the class name is rebuilt from the file's path relative to the scanned directory. Layout discovery is not recursive; see Layouts
Only non-abstract subclasses of Block are registered An abstract class BaseCardBlock in the same directory is not registered as a block
A class whose namespace does not match its path is skipped without an error No exception is thrown and nothing is logged. The block does not appear in the block picker
A missing directory is skipped A namespace whose directory does not exist yet can be listed without causing an error

The third rule is the most common cause of a missing block. Discovery calls is_subclass_of($class, Block::class), which triggers the autoloader. When the class name rebuilt from the file path does not resolve to a class, is_subclass_of() returns false and the file is skipped. When a new block does not appear in the picker, compare its namespace with its directory first.

The explicit register list does not skip silently. Listing a class that is not a subclass of Block throws an InvalidArgumentException at boot with the message [{class}] is not a Mcg\FilamentCms\Contracts\Block. Moving a missing block from discover to register therefore produces an error that names the problem.

Ordering

Blocks are registered in this order: the built-in blocks, then the classes in register, then the classes found by discover. A later registration replaces an earlier one. The registry is keyed by key(), so an application class whose key() returns 'hero' replaces the built-in Hero block without any config entry. Two blocks with the same key do not cause an error; the later one replaces the earlier one without a warning. Check the package's src/Blocks directory before choosing a key.

Two config keys are applied during registration rather than after it. They therefore also apply to blocks the package registered before the application's config was read:

  • cms.blocks.disable is checked before an instance is stored, so a disabled block never enters the registry.
  • cms.blocks.override is resolved in place of the original class, so ['hero' => OurHero::class] takes effect even though Hero was registered first.

Instances

Blocks are resolved through the service container with app($class), so a block class can declare dependencies in its constructor and receive them by injection.

The registry holds one instance per key for the whole request, and every occurrence of that block on the page uses the same instance. Keep constructors inexpensive and store no per-block state on the instance. On a page with three occurrences of one block, the same object renders all three, and a property written during the first render still holds its value during the second.

Content types

Content types are registered by the same mechanism, through cms.content_types.discover and cms.content_types.register. They use the same namespace-to-directory map and the same rule that a later registration replaces an earlier one. A class whose key() returns page replaces the built-in Page content type.

Single-file Livewire blocks are discovered by a separate mechanism with its own directory. It is described in Block reference.

---

Two blocks read the page tree: page_list and navigation. They differ in depth.

  • page_list lists one level: the published children of one page, with their dates and summaries. A blog index is a typical use.
  • navigation is recursive. It renders a page and its descendants, down to a configured depth, as a menu. Its entries are not authored: a new page appears in the menu when it is created, and moving a page in the page tree moves its menu entry. The block marks the current page with aria-current="page" and marks every ancestor of the current page as active, so a side menu shows the reader's position in the tree.

The navigation block has two toggles in its form:

  • Only open the current section. When this is on, a branch is rendered only when the current page is inside it. The markup for every other branch is omitted from the HTML, not hidden with CSS. The block has no client-side toggle, so a branch hidden with CSS would remain in the accessibility tree with no way to open it.
  • Show parent page link. When this is on, the page the tree starts from is rendered above its children, so a reader can return to the section's own page, which usually holds the section's introduction. The link is rendered outside the list, not as its first item, because the page is the parent of the listed pages, and nesting it in the list would mark it as their sibling. The toggle is shown only after a start page is chosen. A tree that starts at the top of the site has no parent page to link to.

Building a custom navigation

NavigationBuilder::tree() builds the tree that the navigation block renders, and is available to application code. It takes a root id, a depth (default 3) and the current path, and returns nested arrays with the keys label, url, is_current, is_active and children. A label uses the page's nav_label attribute when it is set and the page's title otherwise, so a page with a long title can have a short menu entry without being renamed.

NavigationBuilder runs one query that fetches every published page in scope, and nests the results in memory. A recursive query would cost one database round trip per level and fetch no fewer rows, because the whole set is needed: a page three levels down determines whether its grandparent is rendered as active.

navigation and related read the shape of the page tree rather than their own stored fields. The page cache invalidates a page through the content ids a block reports from contentIdsFrom(), and that method can name only pages that existed when the block was saved. A page created afterwards must still appear in the menu. navigation and related therefore return false from isCacheable(), and every page they appear on is excluded from the page cache.

page_list and series_nav are cacheable since version 0.78.15, because each reads only the published children of one parent. ChildPages::for() records that parent in PageCacheTags while the block renders, the cached page is indexed under the parent, and saving, deleting or restoring any child of that parent removes the cached page. Application blocks that read the page tree can use the same mechanism instead of returning false from isCacheable(); see Caching.

---

Document Covers
Installation Installing the package, and upgrading it
Block reference The block class, its methods and its view
Layouts and regions Page chrome, regions and shared regions
Content types Adding a kind of content
Design system Tokens, skins and components
Caching The page cache and what invalidates it
Integrating another plugin Resolvers, events and owning URLs
Your application's own data A layout or a block that queries the application's tables, and what a resolver-owned URL does not support
Pitfalls Failures that produce no error