Skip to content
MCG Filament CMS Demo

Layouts, regions and page width

This document explains the page shell that a Filament CMS page renders into. It covers the six layouts included in the package, how to write and register a layout of your own, how regions and shared regions work, how the four block width presets are resolved, how site navigation and hand-built menus are rendered, and how the 404 page is produced. It is written for the developer who builds or changes a site's templates.

Block authoring is described in Block reference. The design tokens that the layouts consume are described in Design system.

---

What a layout is

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:

  1. It lists the available layouts for the Template field in a page's Page settings.
  2. 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.
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. This is 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; see The docs layout.
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

  1. In a terminal at the site's root, run cms:make-layout with the layout name and its regions:

Shell

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.

  1. 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:

  • The regions are written in the order given to --regions. The page editor lists a layout's regions in the order of their tags in the file, so this is also the order editors see; see Region order in the page editor.
  • main is emitted inside <main class="cms-main" id="cms-content">, at its position in the list. The element with id="cms-content" is the target of the skip link.
  • Every other region is emitted as a bare <x-cms::region> tag, under a Blade comment that says to wrap the regions 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(), so Page Header and page_header both become page-header, matching the package's own region names. Each region's label is the Str::headline() of its name.

--regions=main,sidebar produces this file (the generator's header comment is omitted here):

Blade

<!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')" />

    {{-- Wrap the regions in whatever the design needs. `sidebar.blade.php`
         in the package is the worked example of a two-column split. The
         page editor lists the regions in the order of their tags here. --}}
    <main class="cms-main" id="cms-content">
        <x-cms::region name="main" label="Main content" />
    </main>
    <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:

Blade

<body @class(['cms-body', ...\Mcg\FilamentCms\Support\PageClasses::forBody($cmsAttributes ?? [])])>

Region order in the page editor

The page editor shows one section per region, in the order of the <x-cms::region> tags in the layout file, from top to bottom. LayoutRegions::parse() (src/Support/LayoutRegions.php) reads the tags in that order, and nothing sorts them afterwards. The order in which the page displays the regions can differ from the order of the tags, for three reasons:

  1. CSS can move a region: grid placement, order or `flex-direction: row-reverse` can show a region above or beside one whose tag comes earlier.
  2. A region placed in a named slot (<x-slot:...>) is displayed where the receiving component puts the slot, wherever the slot is written in the file.
  3. Layouts generated before 0.85.1 write main first and every other region after it, whatever order --regions gave.

To make the page editor match the page:

  1. In the layout file in resources/views/vendor/cms/layouts/, move the <x-cms::region> tags (with the markup around each one) into the order the page displays them. Keep each region's name unchanged: blocks are stored with their region's name, so a renamed region loses its blocks.
  2. In the page editor, reload a page that uses the layout. The region sections appear in the new order, with their blocks.

Moving a tag also moves its region on the page, unless CSS positions the region independently of the markup order.

Starting from the included layouts

To start from the package's own layouts instead of the generator's file:

  1. In a terminal at the site's root, run:

Shell

php artisan vendor:publish --tag=cms-views

The command copies all six included layouts from the package's resources/views/layouts/ into resources/views/vendor/cms/layouts/.

  1. 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), 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 <html> 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 </body> 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 drafts.md.
PageClasses::forBody() on <body> 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:

Blade

<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:

Plain text

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() uses depth(0), so resources/views/vendor/cms/layouts/marketing/hero.blade.php is never found. Block discovery is recursive; layout discovery is not. Keep layout files directly in the layouts directory.
  • Only *.blade.php files at the top level are registered. The component name is the filename without .blade.php, so two-column.blade.php becomes cms-layout::two-column.
  • The label is derived, not stored. It is Str::headline() of the name, so two-column is shown as "Two Column". To set a different label, register the layout in cms.layouts.register (see Registering layouts in config/cms.php).

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:

  1. 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.
  2. A content type's layout() method may name a component that was never discovered or registered:

PHP

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:

PHP

'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:

PHP

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',
    ];
}

The rest of the content type interface is described in Content type reference.

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:

PHP

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

---

Regions

A region is a named area of a layout into which blocks are placed. A region is added to a layout by adding an <x-cms::region> tag to the layout file. No other step is required:

Blade

<div class="cms-columns">
    <main class="cms-main" id="cms-content">
        <x-cms::region name="main" label="Main content" />
    </main>

    <aside class="cms-aside">
        <x-cms::region name="sidebar" label="Sidebar" />
    </aside>
</div>

Regions are declared in one place only, so the template and the editor always list the same regions. LayoutRegions (src/Support/LayoutRegions.php) finds regions by scanning the layout's source with a regular expression instead of rendering it. Rendering a layout requires request state, a content payload and a full Blade compile, and the editor has none of these when it decides which fields to draw. The consequence is that only literal region names are detected (name="sidebar", name='sidebar' or :name="'sidebar'"). An editor chooses a region from a fixed list, so a region name has to be a literal in any case.

Region rules

  1. A layout with no region tags has one region, main. That region's blocks are passed to the layout as {{ $slot }}. Layouts written before regions existed therefore work without changes.
  2. **A layout uses either {{ $slot }} or <x-cms::region name="main" />, never both.** Both render the main region's blocks, so a layout containing both renders those blocks twice.
  3. The region name is stored on each block. Renaming sidebar in the template orphans every block already placed in it. A block with no region key belongs to main.
  4. label names the region in the panel and in the front-end editor. It titles the region tabs in the page editor. Since v0.54.0 it is also the text in the tag an editor sees when hovering a block with front-end editing on, for example Region Β· Main content. Visitors never see it. When no label is given, the region name is used, so an unlabeled main region reads Region Β· main. Write the label as the name an editor would recognize the region by, not as an internal note.

Switching a page to a template with fewer regions deletes nothing. Blocks in a region that the new template does not declare stop rendering and remain stored. Switching the page back to a template that declares the region renders them again.

.cms-region

<x-cms::region> wraps its blocks in <div class="cms-region">. A shared region's wrapper also carries cms-region--shared.

When an editor has front-end editing on, the wrapper carries up to three data attributes:

Attribute Present on Read by
data-cms-region Every ordinary region; a shared region only when its area record exists The inline editor, which scopes a drag to it so a block cannot be dragged out of its region
data-cms-region-label Every region The inline editor, which shows it in a hovered block's tag; see Editing on the page
data-cms-owner A shared region whose area record exists The inline editor, which uses it to address the layout area record that owns the blocks

All three attributes are omitted for every other request, including every visitor's. The inline editor is the only code that reads them, and it is not rendered unless editing is active. Emitting them to visitors added markup that nothing used, and in the label's case it put an internal region name into the markup of every public page. Write CSS and JavaScript selectors against .cms-region, which is always present. Do not write a selector that depends on the data attributes.

Do not give .cms-region display: flex or display: grid. A flex or grid container stops the vertical margins of adjacent children from collapsing, so every gap between blocks doubles from 32px to 64px. This was measured on /docs/data-model, which has 15 top-level blocks: with the shipped block-level <div>, the gaps measure 32px and the first block sits 74px below the top of .cms-main, which is identical to having no wrapper at all; with flex or grid, the gaps measure 64px. cms.css pins .cms-region to display: block. All six shipped skins (arcade.css, market.css, marquee.css, press.css, saas.css and signal.css) set --cms-rhythm to the finished gap on the assumption that margins collapse. To add space between blocks, set --cms-rhythm.

The wrapper sits between .cms-main and the blocks, so a selector written as .cms-main > .cms-block no longer matches anything. In a custom skin written against the old structure, change it to .cms-main > .cms-region > .cms-block. Where the rule targets the first block in the column, use .cms-main > .cms-region:first-child > .cms-block:first-child, because the docs and article layouts render their own <h1> above the region.

Shared regions

A shared region takes its blocks from the layout instead of from the page. Every page using that layout renders the same blocks, including pages created after the blocks were placed, without any editor placing anything on them. A region is made shared by adding the shared attribute to its tag:

Blade

<nav class="cms-docs__nav">
    <x-cms::region name="navigation" label="Navigation" shared />
</nav>

LayoutRegions accepts three spellings with the same meaning: shared, shared="true" and :shared="true".

Use a shared region for content that belongs to a section of the site instead of to one page. Examples are a documentation navigation rail, a call to action above the footer, and a notice across a campaign's landing pages. Use an ordinary region for content that describes the page it appears on. A table of contents describes its page, but it still works in a shared region; the reason is explained in The docs layout.

A shared region's blocks are stored in a layout_area content record. The layout_area content type (src/ContentTypes/LayoutArea.php) has no URL, which is the same arrangement snippets use. A layout area therefore uses the existing immutable versions, draft and publish states, version history, diffing, restore, block builder, admin resource and MCP tools, and none of these had to be written a second time.

Shared region rules

  1. A shared region ignores the page's own blocks. A region is either shared or not shared. Rendering both sets would require deciding whether the shared blocks come before or after the page's blocks, and no order is correct for every case. Blocks already stored in a region that later becomes shared are kept on every save and stop rendering.
  2. There is one area per layout and region, identified by its slug. LayoutAreas::slugFor() (src/Support/LayoutAreas.php) derives the slug: cms-layout::docs with region navigation becomes cms-layout-docs-region-navigation. The word region separates the two halves instead of a punctuation mark because the slug is slugged again when it is saved. ContentWriter re-slugs every slug it receives, and an earlier double-hyphen separator was collapsed to a single hyphen on the way into the database, so every lookup then missed by one character. The slug is the only link between the template and the content record, so the slug field is locked in the panel.
  3. Areas are created automatically and never created by hand. LayoutAreas::sync() creates a record for every declared shared region that has none. The Layout areas list in the panel runs sync() each time it loads, and php artisan cms:layout-areas runs it from a deploy script. The list has no Create button, because a record created by hand would carry a slug that matches no region.
  4. An area renders nothing until it is published. Creating the record does not make it render. A visitor, and a preview, see the area's published version. An editor browsing the front end with draft view on sees the area's draft, so that a block saved as a draft from the front end appears where it was placed. An area that is missing, or that exists and has never been published, renders as an empty region and does not cause an error.
  5. Publishing an area purges the whole page cache. A page that embeds a snippet records the reference in content_refs, so republishing that snippet purges exactly the pages that contain it. No record links a page to a shared region, because the layout references the area and the page does not. Every page on the layout may be stale, and no cheaper query identifies which pages use it. See Caching.
  6. Areas are per language. sync() creates the area in the default locale only. Other languages are created with the Translate action, in the same way as for a page. An area with no translation in the language being served renders nothing instead of rendering text in another language. See Multilingual sites.

Filling a shared region

  1. In the admin panel sidebar, click Layout areas. Loading the list creates any area that does not exist yet.
  2. In the Layout areas list, click the area named after the template and the region. The title is the layout's label, a colon and the region's label, for example "Docs: Navigation".
  3. In the area's editor, add blocks.
  4. In the same editor, click Publish. The blocks now render on every page that uses the layout.

Shared regions in the page editor

On a page that uses a layout with a shared region, the page editor replaces that region's block builder with a read-only list of the blocks the area holds and an Edit blocks button.

  • The section carries a Shared Region badge. The explanation is in the badge's tooltip ("This layout includes a shared region. Any block added to this region will appear on any page using this layout.") instead of in a line of text under every such section heading. The badge text comes from cms::cms.panel.content.shared_region_badge, so it is changed by editing the lang file.
  • The section is collapsed by default. Nothing in it can be edited in place, and its content is identical on every page using the template, so an open section would place unchanging text between the editor and the page content.
  • The read-only list shows the area's published version, because the list shows what this page displays to visitors. An area with unpublished changes shows the message "This shared area has unpublished changes."

Edit blocks opens the area's block builder in a slide-over without leaving the page. The slide-over loads the area's draft version, because it is an editing surface and must open on unpublished work instead of discarding it.

The slide-over has its own save buttons, separate from the page's:

Editor's permission Buttons in the slide-over
May publish layout areas Save & publish (the primary button) and Save as draft
May not publish layout areas Save as draft only, as the primary button

The slide-over writes the area record and nothing else; the page's own save is unaffected. A page and an area are two records with separate publish states. A single save button covering both would publish the area's changes onto every other page on the layout whenever an editor published the page they were viewing.

The action is defined on EditContent as editSharedRegionAction(), not on the schema component. The region sections are built inside a closure so that changing the template rebuilds them, and a schema built inside a closure does not keep the component identity Filament needs to re-render the component that owns a modal. When the action is defined on the schema component, the failure is an empty partial that Livewire reports as a missing root tag, and the error message does not name the cause.

The docs layout

The included docs layout shares three of its four regions: page-header (labeled "Page header content"), navigation (labeled "Navigation") and contents (labeled "On this page"). The main region is per page.

The table of contents block works in the shared contents region. The block reads $cmsBlocks, which always holds the blocks of the page being rendered, not the blocks of the area the table of contents block is stored in. One shared table of contents block therefore produces a different and correct heading list on every page. The search block also works in a shared region, because it has no per-page state.

Switching an existing region to shared

Fill and publish the area before the template change reaches visitors. When the shared attribute is deployed, every block already placed in that region on every page stops rendering (shared region rule 1), and an area that has not been published renders nothing (shared region rule 4). Together, these two rules remove the region's content from the site until the area is published.

Shared regions over MCP

The MCP tools enforce shared region rule 1. cms_append_block refuses to append a block to a shared region and returns the area's path. cms_update_content returns a warning instead of refusing, because a block list read back from cms_get_content can legitimately contain blocks left in a region that later became shared. See The MCP server.

---

Width presets and --cms-block-max

Every block has a width setting with one of four values, defined in Block::WIDTHS (src/Contracts/Block.php). The three explicit presets, narrow, wide and bleed, are fixed and resolve to the same width on every layout. normal is the only preset a layout may change.

Preset Label in the block's Width field Class emitted Resolves to
normal Normal β€” whatever this page template calls normal None --cms-width-normal, declared on .cms-block itself, unless the layout changes it
narrow Narrow β€” a reading measure, even on a wide template .cms-block--narrow --cms-width-narrow, a reading measure, on every layout
wide Wide β€” roomier, still contained .cms-block--wide --cms-width-wide
bleed Full bleed β€” edge to edge, out of the column entirely .cms-block--bleed The full viewport width, outside the content column

Three layouts change normal: wide changes it to --cms-width-wide, and home and docs remove the cap entirely. On every other layout, normal is --cms-width-normal.

There is no full preset. full was documented as a value but never existed in Block::WIDTHS. Block::cast() converts a stored full to normal when the block is read.

The cascade

One custom property, --cms-block-max, is set in three places in resources/css/cms.css. The browser resolves it by the cascade, not by selector specificity: a value declared on an element always takes precedence over a value the element would otherwise inherit, whatever the weights of the selectors involved.

CSS

/* 1. The three measurements. The only place to change what they mean. */
@theme {
    --cms-width-narrow: 42rem;   /* Tailwind 2xl */
    --cms-width-normal: 56rem;   /* Tailwind 4xl */
    --cms-width-wide:   72rem;   /* Tailwind 6xl */
}

/* 2. The block's own default, which is what `normal` resolves to. It comes
      first: it has specificity (0,1,0), the same as each preset below, so the
      presets take precedence by source order and an explicit narrow stays
      narrow. */
.cms-block          { --cms-block-max: var(--cms-width-normal); }

/* 3. A preset on the block. The package never emits `--normal`; it is reached
      through a block's CSS classes field, to pin a block to the normal
      measure on one of the layouts in rule 4. */
.cms-block--narrow  { --cms-block-max: var(--cms-width-narrow); }
.cms-block--normal  { --cms-block-max: var(--cms-width-normal); }
.cms-block--wide    { --cms-block-max: var(--cms-width-wide); }

/* 4. The three layouts that change what `normal` means. These rules have
      specificity (0,2,0), which outweighs the block's own default, and they
      apply only to blocks left on normal. */
.cms-main--wide .cms-block:not(.cms-block--narrow, .cms-block--normal, .cms-block--wide, .cms-block--bleed, .cms-block--hero) {
    --cms-block-max: var(--cms-width-wide);
}

.cms-main--home .cms-block:not(.cms-block--narrow, .cms-block--normal, .cms-block--wide, .cms-block--bleed, .cms-block--hero),
.cms-main--docs .cms-block:not(.cms-block--narrow, .cms-block--normal, .cms-block--wide, .cms-block--bleed, .cms-block--hero) {
    --cms-block-max: none;
}

/* Read in one place only. */
.cms-block          { max-width: var(--cms-block-max); }

Rule 4 is the reason wide, home and docs change block width and the other layouts do not. It is also the only form of rule that works. Setting --cms-block-max on .cms-main appears equivalent and has no effect, because every .cms-block declares the property on itself, and inheritance never overrides a declared value. The wide layout was originally written that way, and for five releases it rendered blocks at the normal measure, so choosing the wide template produced no visible change. A custom layout that changes what normal means must target the block, never the container.

.cms-block--hero appears in each exclusion list because the hero removes its own cap with a (0,1,0) rule, .cms-block--hero { --cms-block-max: none; }, and rule 4 would otherwise outweigh it. Without the exclusion, a hero on the wide layout would be capped at 72rem.

Measured widths at a 1280px viewport:

Layout normal narrow wide bleed
default, article (cms-main--reading) 896px 672px 1152px No cap
wide 1152px 672px 1152px No cap
home, docs No cap 672px 1152px No cap

Why normal does not inherit from the layout

normal previously inherited its width from the page template, on the basis that the template should define what normal means. That design merged presets: on the reading layout normal resolved to the same width as narrow, and on the wide layout normal resolved to the same width as wide. The editor offered four options that produced three distinct results, so the control misdescribed its own behavior.

The layouts that do need to change normal now declare it on the block, in rule 4 above, and exclude the three explicit presets. narrow is therefore 672px on every layout and wide is 1152px on every layout. Only normal varies, and its label in the block's Width field says so: "Normal β€” whatever this page template calls normal".

Why normal emits no class

Block::attributesFrom() emits no class for normal. The html and snippet blocks render without a wrapper element unless $cmsAttrs carries something, so a class for normal would have added a <div> wrapper to every raw embed that previously had none. The default is declared on .cms-block in CSS instead, and those two blocks continue to render with no wrapper element unless the editor sets an option explicitly.

Full bleed

CSS

.cms-block--bleed {
    --cms-block-max: none;
    max-width: none;
    width: 100vw;
    margin-inline: calc(50% - 50vw);
}

:root:has(.cms-block--bleed),
:root:has(.cms-block--section),
.cms-body:has(.cms-block--bleed),
.cms-body:has(.cms-block--section) { overflow-x: clip; }

The section block bleeds with the same geometry, so it is in the same clip list. A new block that bleeds must be added to that list. Three parts of these rules are required:

  1. The overflow-x: clip rule. 100vw includes the width of the vertical scrollbar. Without the clip, the page scrolls sideways by the scrollbar's width. Measured at a 1280px viewport, a 1280px-wide bleed block starts at -7px and the whole document scrolls horizontally.
  2. clip instead of hidden. overflow-x: hidden makes the element a scroll container, and a scroll container stops every position: sticky descendant from sticking, including the site header.
  3. The --cms-block-max: none reset on the bleeding block. The hero resets it for the same reason. Without the reset, a block nested inside the bleeding block (for example, the blocks a snippet embeds) inherits the layout's cap and centers itself, so the wrapper spans the viewport while its visible content stays at the column width.

Changing the measurements

To change a measurement site-wide or for one skin, override the variable:

CSS

:root { --cms-width-wide: 80rem; }

[data-cms-skin='press'] { --cms-width-narrow: 38rem; }

To give a custom layout its own value for normal, target the block, as explained under The cascade:

CSS

.cms-main--gallery .cms-block:not(.cms-block--narrow, .cms-block--normal, .cms-block--wide, .cms-block--bleed, .cms-block--hero) {
    --cms-block-max: var(--cms-width-wide);
}

To pin one block back to the normal measure on such a layout, add cms-block--normal to the block's CSS classes field in the block editor. The stylesheet defines that class for this purpose only; no markup the package renders emits it.

To exempt a single block type from the cap, set the property to none on its class. The hero does this so that it fills the width of its container:

CSS

.cms-block--hero { --cms-block-max: none; }

Width in block views

A block view needs only to merge $cmsAttrs into its root element to take part in the width system. Do not set max-width in a block's own CSS rule. If a block needs a different cap, set --cms-block-max and let the .cms-block rule apply it. A block that sets max-width directly is outside the width system, and the editor's Width setting has no effect on it, with no error.

Block::cast() migrates older stored shapes when a block is read, instead of a database migration rewriting them. Block data is JSON stored inside immutable versions, and a migration would have to rewrite every version ever saved. The migrations are:

  1. A container key is removed. Any value other than default is moved to width.
  2. A stored width of full becomes normal.
  3. A missing or unrecognized width becomes normal.

---

A layout renders the site's navigation with <x-cms::navigation />. The included layouts render it through <x-cms::site-header> and <x-cms::site-footer>. By default, the component derives the menu from the content tree: the published pages of the content types listed in cms.navigation.types (default ['page']) that sit at the root set by cms.navigation.root, in sort order. It outputs a <nav> element and sets aria-current="page" on the link to the current page.

Blade

{{-- Defaults: a "Home" link first, current path taken from the request --}}
<x-cms::navigation />

{{-- No home link, and a site-specific class --}}
<x-cms::navigation :home="null" class="my-nav" />

A derived menu suits a site whose navigation matches its page tree, and it is the default. The derived list is controlled by four settings:

  • cms.navigation.types sets which content types appear.
  • cms.navigation.root sets where the menu starts. See Where the menu starts.
  • Navigation label, a field on the page itself, replaces the page's title wherever the page appears in a menu. A page titled "Every save is a new version" can appear as "Notes" in the header without changing its heading. The value is stored as the nav_label attribute.
  • Hide from navigation, a toggle on the page itself, removes the page from derived navigation. See Where a page appears.

Where a page appears

A page can be published, reachable, indexable and linkable, and still be excluded from the lists the CMS assembles automatically. Every routable content type has three independent toggles under Where this page appears in Page settings. All three are off by default.

Toggle Attribute Removes the page from
Hide from navigation excludefromnavigation Navigation derived from the page tree: the header, the navigation block and the navigation API. A hidden parent page also hides its children.
Hide from page lists excludefrompagelists Page list blocks, series navigation, related items and tag archives.
Hide from sitemap.xml excludefromsitemap sitemap.xml.

None of the toggles changes the page's address, and none of them turns on another. A privacy policy is typically hidden from navigation and kept in the sitemap. A search page is typically hidden from both and remains reachable.

The toggles have two limits:

  1. They are not access control. A hidden page is served to anyone who has its URL. To stop a page being readable, schedule it or leave it unpublished.
  2. Hiding a page from the sitemap does not set noindex. The sitemap stops listing the page, but a crawler that finds a link to it will still index it. The Hide from search engines toggle in the SEO settings asks crawlers not to index the page, and a page with that toggle on is left out of the sitemap regardless of Hide from sitemap.xml.

The toggles are version attributes, so they belong to the version. Switching one on takes effect when the page is published, and restoring an older version restores the toggle values that version had.

exclude_from_page_lists is also copied to a column of the same name on the content table when the page is published. A tag archive paginates in the database, so it must filter in SQL; rejecting rows after the query would return short pages against a total count that still included the rejected rows. No other code reads the column, and the version attribute remains the source of truth.

Where the menu starts

cms.navigation.root defaults to 'auto'. With 'auto', a page counts as top level if it is at the top of the page tree or is a child of the homepage.

Everywhere else in the package, those two positions are equivalent. A page filed under Home keeps its own address (/about, not /home/about), because the path builder skips a homepage parent. Filing the whole site under Home therefore does not change any URL, and it keeps the page tree readable once a second locale adds its own home page, instead of every page in both languages sitting at one level.

Accepting both positions makes 'auto' safe on an existing site:

  1. A flat site keeps exactly the menu it had.
  2. A site with its pages filed under Home gets a menu built from those pages.
  3. A site part way through moving its pages under Home shows all of them instead of half.
Value The menu contains
'auto' (default) Pages at the top of the tree, plus the homepage's children
'top' Only pages at the very top of the tree
'/products' or a content id The children of that page

Set 'top' when pages are filed under the homepage and must not appear in the menu. A path or id that does not resolve to a page falls back to the top of the tree instead of producing an empty menu.

The same setting determines where a navigation block starts when its Start from field is left empty.

cms.navigation.root works together with cms.panel.default_parent, which sets the initial parent of a new page (default 'homepage'). Filing new pages under the homepage while cms.navigation.root is 'top' creates pages that never appear in the menu.

Hand-built menus

When the menu should not follow the page tree, build a menu under Menus in the admin panel and pass its handle to the component in the layout:

Blade

<x-cms::navigation menu="main" />
<x-cms::navigation menu="footer" aria-label="Footer" />

The included layouts read the handles from cms.theme.menus.header and cms.theme.menus.footer, so a handle set in those keys is rendered without editing a template.

A handle with no menu behind it falls back to the derived list and writes a warning to the log naming the handle ("CMS navigation: no menu with the handle […] exists, so the page tree was used instead."). This covers deleting a menu or renaming a handle on a live site. Before this fallback existed, a missing menu rendered nothing, which removed the site's navigation without any error.

A null handle also falls back to the derived list. cms.theme.menus.header and cms.theme.menus.footer are both null by default, and both the header and the footer then render navigation derived from the page tree. A site that has never configured a menu therefore has navigation in both places. Before this change, the footer rendered nothing in that case, so every default installation displayed an empty footer.

A menu that exists and has no items renders empty and logs nothing, because an empty menu is a state an editor chose.

A menu item is one of three kinds: a page on this site, a web address, or a heading that labels a group of items without linking anywhere. Every item has an optional label, a CSS classes field, a visibility switch and up to two levels of children.

A top-level item also has a display mode, set in the panel in the Opens as field:

Opens as option What the header renders
A single link One link. The item's children are not rendered.
A dropdown of links The item's children in one column.
A panel of columns A mega panel. Each child is a column heading, and each grandchild is a link in that column.

The display mode is stored on the item, not inferred from whether the item has children. Adding a child to an item therefore never changes how the header renders that item; otherwise, filing one page under an item would turn it into a dropdown without anyone choosing that. The reverse case follows from the same rule: an item left on A single link renders none of its children, and the panel shows a warning on such an item ("This item opens as a single link, so these sub-items are not shown on the site.").

Only a link inside a mega panel can carry a description and an image. Those two fields are what distinguish a mega panel from a wide dropdown, and the panel shows them only on items where the renderer draws them.

Building a menu is described in For content authors. The menu renderer performs these tasks automatically:

  • It follows pages that move. A page item stores a content id, never a path.
  • It hides broken items. An item pointing at a page that is unpublished or deleted is left out of the menu instead of linking to a 404, and returns when the page is restored. The item remains visible in the panel.
  • It adds rel="noopener noreferrer" to every item that opens in a new tab.
  • It marks a parent item active while the reader is on one of its children, which highlights the dropdown. The active state propagates through both child levels, so a top-level item is highlighted while the reader is on a page linked from the third level of a mega panel.
  • It opens every panel on click, not on hover. Dropdowns and mega panels are <details> disclosure elements, the same element the language switcher uses. Hover was replaced in v0.76.0 for two reasons:
  1. WCAG 2.1 success criterion 1.4.13 requires content shown on hover to be dismissible without moving the pointer, and a CSS-only hover panel offers no way to do that.
  2. Hover panels were unusable on touch screens, which have no hover, and where a heading-type parent is not a link that can be tapped.

    A <details> element provides keyboard operation and the expanded state natively and needs no JavaScript. The header's existing script adds closing on an outside click and on Escape as an enhancement.
  • It keeps a linked parent reachable. A <summary> element toggles its panel instead of navigating, so an item that both links to a page and opens a panel renders its own destination as the first entry in the panel, labeled ":label overview" (for example, "Products overview").
  • It flushes the page cache whenever a menu changes. A menu appears in the layout of every page, so no smaller set of pages can be invalidated.

Custom navigation markup

There are two ways to replace the navigation markup entirely:

  1. Create resources/views/vendor/cms/components/navigation.blade.php. Laravel resolves that file ahead of the package's component view.
  2. Skip the component and call NavigationBuilder::items() directly. Each item has the keys label, path, url, is_active and is_current. This suits a bespoke header, which would otherwise have to override the package's cms-nav__* classes. NavigationBuilder::tree() returns the nested equivalent and is described in Architecture.

---

The 404 page

A path that no route claims reaches the CMS catch-all route, which throws NotFoundHttpException. The RendersCmsNotFound middleware (src/Http/Middleware/RendersCmsNotFound.php) replaces the resulting 404 response with the view cms::errors.404. That view renders the site's own header and footer around an error message, so a visitor who lands on it can leave through the navigation. The response has a 404 status and one robots meta tag, noindex, follow.

The view shipped with the theme from the start, but before this middleware no installation could display it. Every not-found path throws, Laravel resolves the exception against the application's errors.404 view, and the package's file is cms::errors.404, a namespace that resolution never checks. The only code rendering the view was a test that called view() directly, so the test suite passed while no visitor could reach the page.

The middleware has the following scope and exceptions:

  1. It is attached to the CMS's own routes only: the catch-all route and the tag archive route. A 404 raised by the application's own controller is handled by the application, because the CMS has no information about routes it does not define. The middleware also does nothing when cms.route.frontend is off, because that setting registers the routes the middleware is attached to.
  2. **An application with its own resources/views/errors/404.blade.php keeps it.** The middleware passes the 404 through unchanged and Laravel renders exactly what it rendered before. Published layouts and block views follow the same rule: the application's file takes precedence. Creating that file is how to opt out of the CMS 404 page completely.
  3. A request that expects JSON keeps the JSON 404 that Laravel produces, because a page of HTML is not a usable response for an API client.

To restyle the page instead of replacing it, publish the view to resources/views/vendor/cms/errors/404.blade.php and edit the copy.

The page is rendered after the DetectsCmsLocale middleware has run, so its strings and the header and footer are in the language of the requested URL's locale prefix. A request for /fr-ca/pas-de-page receives the page in French.

Why the middleware checks the status code

RendersCmsNotFound inspects the response's status code instead of catching the exception. Illuminate\Routing\Pipeline overrides handleException() so that it renders a throwable through the exception handler *inside* the pipeline. $next() therefore returns a finished 404 response, and a catch block around $next() never runs. The middleware was first written with a catch and had no effect.

The catch block is still present for the one case in which the pipeline rethrows the exception: when no ExceptionHandler is bound. That case is uncommon, and without the catch it would produce a raw stack trace.