Skip to content
MCG Filament CMS Demo

The data model

This page explains how the CMS stores a page and how an HTTP request becomes rendered HTML. It is written for a developer who has installed the package and intends to extend it. 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.

Content

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 under The content.title column below.

A content row is not necessarily a page. The type column names a content type, and page is one of several. A snippet is a content row with no URL, and an application can register its own types. The entity is therefore named Content rather than Page, and one table serves the Pages list, the Snippets list and every custom content type the panel shows.

Version

A 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.

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.

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.

Block

A 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.

Content type

A 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.

Layout

A 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.

Skin

A 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. The package ships six skins: press, signal, marquee, saas, arcade and market.

Built-in content types

FilamentCmsServiceProvider::registerContentTypes() registers three built-in content types: page, snippet and layout_area.

The 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
`layout_area` The blocks belonging to one shared region of one layout When `cms.layout_areas.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.

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 package.

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.