Layouts
A layout is an anonymous Blade component. It is not a view rendered by a controller, and it has no PHP class. Saving a file named wide.blade.php into resources/views/vendor/cms/layouts makes the component cms-layout::wide renderable. No registration step and no PHP code are required.
LayoutRegistry (src/Support/LayoutRegistry.php) performs two tasks that a filename cannot perform on its own:
- It lists the available layouts for the Template field in a page's Page settings.
- It refuses a stored layout value that names no known layout.
LayoutRegistry::resolve()returns the fallback,cms-layout::default, instead of throwing an exception. A layout that is deleted while content still references it therefore renders those pages with the default layout, and the pages stay online.
The layout is stored on the content version, not on the content row. A template change is therefore drafted, previewed, compared in the version history and rolled back in the same way as any other edit.
Layouts included in the package
The package ships six layouts in resources/views/layouts/:
| Component | What it renders |
|---|---|
| cms-layout::default | The site header, one content column and the site footer. Blocks left on the normal width run at --cms-width-normal. |
| cms-layout::wide | The same shell as default. Blocks left on the normal width run at --cms-width-wide instead of --cms-width-normal. |
| cms-layout::article | A long-form layout. It renders the title, dateline and standfirst from the version's attributes before the blocks. |
| cms-layout::sidebar | Two regions, main and sidebar. The columns stack below 768px (Tailwind md); at 768px and wider they sit side by side and the sidebar is sticky. |
| cms-layout::docs | Four regions: page-header, navigation, main and contents, the arrangement laravel.com/docs uses. Both rails are sticky at 1024px and wider. The navigation rail is hidden below 1024px (Tailwind lg) and the contents rail is hidden below 1280px (Tailwind xl). Three of the four regions are shared. |
| cms-layout::home | One region with no width cap, for a sequence of full-width bands instead of a reading column. |
article is the only included layout that renders content itself instead of leaving all content to blocks. An article's title, date and standfirst appear in the same position on every post. Rebuilding them from a heading block and a rich-text block on each post produces pages that differ from one another.
Three layouts change how wide their blocks run: wide, home and docs. The default and article layouts carry the class cms-main--reading, but they leave blocks at the normal measure. The narrower reading measure is applied per block with the narrow width preset. See Width presets.
Writing a layout
Generating a layout file
- In a terminal at the site's root, run
cms:make-layoutwith the layout name and its regions:
php artisan cms:make-layout Marketing --regions=main,sidebar
The command writes resources/views/vendor/cms/layouts/marketing.blade.php and prints Layout [cms-layout::marketing] created at β¦ followed by the list of regions. The name is converted to kebab case, so Marketing becomes marketing. When the name or the regions are omitted, the command asks for them. --regions defaults to main. --force overwrites an existing file.
- In the page editor, open any page's Page settings. The new layout appears in the Template field as soon as the file exists, labeled "Marketing".
The layout must be in resources/views/vendor/cms/layouts. The service provider registers that directory as an anonymous component path ahead of the package's own directory, and LayoutRegistry scans it. A layout written to any other directory is neither renderable as cms-layout::{name} nor listed in the Template field.
The generated file places the regions as follows:
mainis emitted inside<main class="cms-main" id="cms-content">. The element withid="cms-content"is the target of the skip link.- Every other region is emitted as a bare
<x-cms::region>tag aftermain, under a Blade comment that says to wrap it in whatever the design needs. The generator does not guess whether a region is a sidebar, a banner or a footer strip, because guessed markup would have to be deleted before the intended markup could replace it. - Region names are passed through
Str::slug(), soPage Headerandpage_headerboth becomepage-header, matching the package's own region names. Each region'slabelis theStr::headline()of its name.
--regions=main,sidebar produces this file (the generator's header comment is omitted here):
<!DOCTYPE html>
<html @foreach (\Mcg\FilamentCms\Support\Theme::htmlAttributes() as $attribute => $value) {{ $attribute }}="{{ $value }}" @endforeach>
<head>
<x-cms::head :seo="$seo ?? []" :fallback-title="$fallbackTitle ?? null" />
</head>
<body @class(['cms-body', ...\Mcg\FilamentCms\Support\PageClasses::forBody($cmsAttributes ?? [])])>
<x-cms::draft-banner :content="$cmsContent ?? null" :version="$cmsVersion ?? null" />
<x-cms::site-header :menu="config('cms.theme.menus.header')" />
<main class="cms-main" id="cms-content">
<x-cms::region name="main" label="Main content" />
</main>
{{-- Wrap these in whatever the design needs. `sidebar.blade.php`
in the package is the worked example of a two-column split. --}}
<x-cms::region name="sidebar" label="Sidebar" />
<x-cms::site-footer :menu="config('cms.theme.menus.footer')" />
@stack('cms-scripts')
</body>
</html>
The generated <body> tag calls PageClasses::forBody(), as all six included layouts do, so the classes typed into the Custom classes field in Page settings are written onto <body>.
Layouts generated before version 0.78.16 write <body class="cms-body"> without forBody(), and the Custom classes field has no effect on pages using them. To fix such a layout, open its file in resources/views/vendor/cms/layouts/ and replace its <body> tag with:
<body @class(['cms-body', ...\Mcg\FilamentCms\Support\PageClasses::forBody($cmsAttributes ?? [])])>
Starting from the included layouts
To start from the package's own layouts instead of the generator's file:
- In a terminal at the site's root, run
php artisan vendor:publish --tag=cms-views. The command copies all six included layouts from the package'sresources/views/layouts/intoresources/views/vendor/cms/layouts/. - In
resources/views/vendor/cms/layouts/, delete every published file you do not intend to change. A published file replaces the package's file of the same name (see How a published layout overrides the package's, below), and Laravel resolves the override per file, so deleting a published copy returns that layout to the package's version.
Required elements
Every layout must contain the following elements. The table states what stops working when each one is missing.
| Element | Effect of omitting it |
|---|---|
| Theme::htmlAttributes() on the html element | The html element has no lang, data-cms-skin or data-cms-theme attribute. Skins and the forced light or dark mode have no effect, because every skin's rules are scoped to a [data-cms-skin] selector and none of them match without the attribute. |
| x-cms::head | The page has no SEO tags, no structured data, no font loading, no @vite assets, no theme flash guard (the inline script that applies a stored light or dark choice before first paint) and no RSS feed autodiscovery link. |
| @stack('cms-scripts') before the closing body tag | Pages that need Alpine or Livewire do not load them, the cookie-consent banner is not rendered and draft live reload does not run. |
| $slot or x-cms::region | No blocks render. |
| x-cms::draft-banner | An editor previewing a draft sees no indication that the page is a draft, and live reload does not run. See Draft view and live reload. |
| PageClasses::forBody() on the body element | The Custom classes field in a page's Page settings has no effect. The field still saves its value, so an editor can set a class, write a CSS rule against it and find that the rule never matches on pages using this layout, while it matches on every layout that calls forBody(). |
Theme::htmlAttributes() returns three attributes for one element. Omitting it disables the whole skin system without any error: the page renders in the base theme permanently, and nothing reports a problem.
A layout that builds its own <head> instead of calling <x-cms::head> also loses the JSON-LD structured data graph. Emit it with:
<x-cms::structured-data :content="$cmsContent ?? null" :version="$cmsVersion ?? null" />
Both arguments are optional. With neither argument, the component still publishes the publisher node and the site node. The page node requires the content and the version.
@stack('cms-scripts') is the element most often omitted. <x-cms::head> pushes its scripts to that stack instead of emitting them directly, so that a script added to the package in a later release reaches layouts written before that script existed. A layout without the stack therefore also loses every script added in later releases.
Registration and discovery
A layout has no class. The filename is its registration, and the directory the file is in determines whether the layout belongs to the package or to the site:
packages/filament-cms/resources/views/layouts/{name}.blade.php β cms-layout::{name}
resources/views/vendor/cms/layouts/{name}.blade.php β cms-layout::{name}
Discovery is controlled by cms.layouts.discover, which is true by default. It scans the two directories above in the order shown, the package's directory first. A later registration of the same component name replaces the earlier one, so a published file with the same name as a package layout relabels the existing entry instead of adding a second entry.
- The scan is not recursive.
LayoutRegistry::discover()usesdepth(0), soresources/views/vendor/cms/layouts/marketing/hero.blade.phpis never found. Block discovery is recursive; layout discovery is not. Keep layout files directly in thelayoutsdirectory. - Only
*.blade.phpfiles at the top level are registered. The component name is the filename without.blade.php, sotwo-column.blade.phpbecomescms-layout::two-column. - The label is derived, not stored. It is
Str::headline()of the name, sotwo-columnis shown as "Two Column". To set a different label, register the layout incms.layouts.register(see below).
Stored layout values and content type defaults
ContentLayout::resolve() (src/Support/ContentLayout.php) passes two arguments to LayoutRegistry::resolve(): the layout value stored on the version, and the content type's default from its layout() method. Only the stored value is checked against the registry. The fallback is used exactly as given. This has two consequences:
- A stored value that names an unregistered component falls back to the content type's default. A hand-edited database row cannot make the site render an arbitrary Blade component.
- A content type's
layout()method may name a component that was never discovered or registered:
public function layout(): string
{
return 'components.layouts.marketing'; // the site's own namespace, not registered
}
That layout renders correctly and never appears in the Template field. This is the usual intent for a content type that is pinned to one template.
Registering layouts in config/cms.php
cms.layouts.register maps component names to labels. It registers a component that discovery does not find, or relabels one that it does:
'layouts' => [
'discover' => true,
'register' => [
'cms-layout::marketing' => 'Marketing (full width)',
'components.layouts.bespoke' => 'Bespoke',
],
],
A content type can set its default layout and restrict the layouts an editor may choose. The rest of the content type interface is described in Content types.
public function layout(): string // the default for this type
{
return 'cms-layout::article';
}
public function availableLayouts(): ?array // null = every registered layout
{ // [] = none; pinned to layout()
return [
'cms-layout::article' => 'Article',
'cms-layout::default' => 'Plain page',
];
}
How a published layout overrides the package's
Laravel's vendor view precedence does not apply to layouts automatically. When this ordering is wrong, the symptom is a published layout that has no effect.
View namespaces receive vendor precedence automatically. Anonymous component paths do not: Laravel searches them in the order they were registered. The service provider therefore registers the site's published directory first and the package's directory second:
if (is_dir($published = resource_path('views/vendor/cms/layouts'))) {
Blade::anonymousComponentPath($published, 'cms-layout');
}
Blade::anonymousComponentPath(__DIR__.'/../resources/views/layouts', 'cms-layout');
These two calls are the complete override mechanism. Without the first call, the layout stub that cms:install publishes would never render. LayoutRegions searches the same paths in the same order, so the regions it detects for a layout always come from the file that renders.
A published layout named default replaces the package's default layout for every content type that does not name a layout of its own, for every preview, and for every test that runs afterwards in the same process. cms:make-layout prints a warning when the name is default: replacing the default is a legitimate choice, and the warning prevents it from happening by accident. See Known traps.