Skip to content
MCG Filament CMS Demo

Building a page template

This tutorial builds one page layout, Landing, from the Artisan generator to a layout an editor can choose for a page. A layout is the document a page renders inside: the <html> and <head> elements, the site header and footer, and the named regions that blocks are placed into. The tutorial is written for a developer adding a layout to a site that runs the mcg/filament-cms package.

1. Generate the layout

  1. In a terminal at the site's root, run the generator with the layout's name and the regions it holds:

Shell

php artisan cms:make-layout Landing --regions=hero,main,call-to-action

The command prints Layout [cms-layout::landing] created at followed by the file path, and a line listing Regions: hero, main, call-to-action.

The command writes resources/views/vendor/cms/layouts/landing.blade.php. The layout's component name is cms-layout::landing, and it is selectable in the editor as soon as the file exists. The package registers that directory as an anonymous component path and scans it for layouts, so a file saved there needs no registration step.

--regions defaults to main when it is omitted. The generator converts each region name to kebab case with Str::slug(), so call_to_action and Call To Action both become call-to-action. Regions can also be added later by editing the file.

The package's six shipped layouts (article, default, docs, home, sidebar and wide) can be copied into the same directory with php artisan vendor:publish --tag=cms-views. That command copies all six files. Each copied file then overrides the package's layout of the same name and no longer receives changes from package updates, so delete every copied file that is not being edited.

2. Read the generated layout

The layout is an anonymous Blade component: it has no PHP class and is not registered anywhere. The file name is the component name. The generated file contains the following markup:

resources/views/vendor/cms/layouts/landing.blade.php Blade

{{-- Landing

     Generated by cms:make-layout. Yours to edit; the package will not overwrite
     it, and it does not inherit changes from the package's own layouts.

     Region names are storage keys. A block is saved with the region it was
     authored into, so renaming one here orphans every block already placed in
     it. Add regions freely; rename them only before anybody has used this
     layout. --}}
<!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>
{{-- PageClasses::forBody() writes the classes typed into the page's Custom
     classes field onto <body>. Without it that field has no effect on pages
     using this layout. --}}
<body @class(['cms-body', ...\Mcg\FilamentCms\Support\PageClasses::forBody($cmsAttributes ?? [])])>
    {{-- Without this an editor previewing a draft gets no sign they are looking
         at one, and no live reload when it changes. --}}
    <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="hero" label="Hero" />
    <x-cms::region name="call-to-action" label="Call To Action" />

    <x-cms::site-footer :menu="config('cms.theme.menus.footer')" />

    {{-- Alpine and Livewire land here, and only on a page carrying a block that
         asked for them. Dropping this stack makes every interactive block on
         every page using this layout silently inert. --}}
    @stack('cms-scripts')
</body>
</html>

The <body> tag is the same one the package's six shipped layouts use, in resources/views/layouts/*.blade.php. wide.blade.php adds one extra class, cms-body--wide.

The generator places the main region inside <main class="cms-main" id="cms-content">. id="cms-content" is the target of the skip link that <x-cms::site-header> renders first on the page; without it, a keyboard user has to tab through the whole navigation on every page. The generator emits every other region without a wrapper, after </main>, because it cannot know whether a region is a hero band, a sidebar or a footer strip.

3. Arrange the regions

  1. In resources/views/vendor/cms/layouts/landing.blade.php, replace the <main> element and the two bare regions with this markup, which moves the hero above the main content and wraps the call to action in an <aside>:

resources/views/vendor/cms/layouts/landing.blade.php Blade

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

<aside class="cms-landing__cta">
    <x-cms::region name="call-to-action" label="Call to action" />
</aside>

The name attribute is the region's storage key and must not change once the layout is in use. The label attribute is the heading the editor sees above the region's block list, and it can be edited at any time.

What the required parts do

Part What it does
Theme::htmlAttributes() Writes the lang, data-cms-skin and data-cms-theme attributes onto <html>. Without it, the page ignores the site's skin and the visitor's light or dark choice.
<x-cms::head> Renders the whole document head: the title, meta tags, Open Graph tags, the canonical link, the stylesheet and the font link. It renders @vite only when cms.vite.entries is set and public/build/manifest.json exists, so the page still renders on a host that has never built assets.
PageClasses::forBody() Writes the classes typed into the page's Custom classes field onto <body>. Without it, that field has no effect on pages using the layout.
<x-cms::draft-banner> Renders nothing for a visitor. For a signed-in editor, it states whether the version being viewed is a draft or the published version.
<x-cms::region> Declares a region that blocks are placed into. Adding the tag is the complete procedure for adding a region: LayoutRegions finds regions by scanning the template file for the tag.
@stack('cms-scripts') The stack interactive blocks push their JavaScript to. A page with no interactive block loads no JavaScript from it.

4. Style the new parts

The classes the package already defines, such as cms-body, cms-main and cms-header, are styled by the package. Only the classes added in step 2 need rules.

  1. In resources/css/app.css, add a rule for the call-to-action wrapper:

resources/css/app.css CSS

@layer components {
    .cms-landing__cta {
        @apply py-16;
        background: var(--color-cms-sunken);
    }
}
  1. In the same terminal, rebuild the assets:

Shell

npm run build

5. Use the layout

A page uses the layout in one of two ways: an editor chooses it for one page, or a content type makes it the default for every page of that type.

Choosing the layout for one page

  1. In the admin panel, open Pages under Content Manager and open a page for editing.
  2. Expand the collapsed Page settings section at the top of the form.
  3. In the Template select, choose Landing. The label is derived from the file name with Str::headline().
  4. Click Save & publish.

The Template select is shown only when the page's content type offers more than one layout. The choice is stored on the version, so a template change can be previewed and rolled back.

Making the layout the default for a content type

  1. In the content type's class, for example app/Cms/ContentTypes/LandingPage.php, return the layout's component name from layout(). Optionally, restrict the choices offered in the Template select with availableLayouts():

app/Cms/ContentTypes/LandingPage.php PHP

public function layout(): string
{
    return 'cms-layout::landing';
}

/**
 * Optional. Null offers every registered layout; a map of component to
 * label narrows the choice; an empty array pins the type to layout() and
 * removes the select.
 *
 * @return array<string, string>|null
 */
public function availableLayouts(): ?array
{
    return [
        'cms-layout::landing' => 'Landing',
        'cms-layout::default' => 'Default',
    ];
}

availableLayouts() returns an array keyed by component name. A plain list such as ['cms-layout::landing', 'cms-layout::default'] matches no registered layout, because ContentForm::layoutOptions() intersects the array's keys with the registered component names.

Common mistakes