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:
- validation
- collection of media references and content references
- extraction of search text
- the page cacheability check
- the decision to load Alpine
- 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
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):
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:
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.
// 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:
'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:
'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.disableis checked before an instance is stored, so a disabled block never enters the registry.cms.blocks.overrideis resolved in place of the original class, so['hero' => OurHero::class]takes effect even thoughHerowas 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.
---
Navigation
Two blocks read the page tree: page_list and navigation. They differ in depth.
page_listlists one level: the published children of one page, with their dates and summaries. A blog index is a typical use.navigationis 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 witharia-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 blocks and the page cache
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.
---
Related documents
| 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 |