Skip to content
MCG Filament CMS Demo

Blocks

This page explains how to write a block for a site running the Filament CMS package: deciding whether a new block is needed, generating it, registering it, the methods of the Block contract, validation, caching, container blocks, Livewire blocks, testing, styling, overriding the package's own blocks, and restricting who can place a block. It is written for the developer who adds blocks to a host application.

A block consists of one PHP class and one Blade view. No other file is generated, compiled or registered to add a block. A block that has to respond to input after the page has loaded takes a second shape: one Livewire single-file component that carries its own block definition and has no class. The sections on fields, validation, caching and search apply to both shapes.

Before writing one

A new block is the last of four options. For a feature or design request, work through the options in this order and stop at the first one that meets the request:

  1. A core block as it is, given the right data, a different width, or a custom_classes entry that the skin styles. The Blocks & layouts screen in the admin panel (/admin/gallery, under the Content Manager group) and the MCP tools cms_list_blocks and cms_describe_block list every registered block and its fields.
  2. A core block restyled by the site's skin, when the difference is color, type, spacing or shape. See Skins and the design-token system.
  3. An override of a core block's view, when the block's fields fit and only its markup differs by a small amount. See Overriding and replacing a block. An overriding view stops receiving the package's fixes to that view, so the further it departs from the package's markup, the more upstream changes it misses.
  4. A new block, generated as described below, only when the first three options cannot meet the request.

For a component taken from a design file, the MCP tool cms_match_block compares the component against every registered block and returns one of four recommendations: reuse, retoken, override_view or new_block.

Generating a block

php artisan cms:make-block generates a block in one of two shapes, selected by the --livewire flag. The key is the class name in snake_case (Str::snake()), the label is the class name as a headline (Str::headline()), and the view file name is the class name in kebab-case (Str::kebab()).

The guided path asks questions about the block and writes a schema, rules, defaults and markup that agree with each other. It runs when the command is given --interactive in an interactive terminal, or when the command is run with no class name, in which case it asks What should the block be called?, Make it a single-file Livewire block? and Describe its fields now? (default yes). Its first question is What does it hold?, with three answers: One set of fields, A repeating list of the same fields, and Other blocks. The third answer creates a container. Choose it for anything shaped like tabs or columns: a repeater gives every row the same fixed fields, so a tab group built as a repeater offers every tab the same fields whether that tab uses them or not.

In a terminal at the site's root:

Shell

php artisan cms:make-block TeamRoster

The command creates app/Cms/Blocks/TeamRoster.php (key team_roster, label Team Roster) and resources/views/cms/blocks/team-roster.blade.php, and the class names the view. A class in app/Cms/Blocks is registered automatically through cms.blocks.discover.

With --livewire, the command writes one file instead, resources/views/livewire/blocks/team-roster.blade.php, which holds the schema, the validation and the markup together and has no class. See Livewire blocks.

The generated class declares its own view() method:

app/Cms/Blocks/TeamRoster.php PHP

public function view(): string
{
    return 'cms.blocks.team-roster';
}

Keep this method. Block::view() defaults to cms::blocks.{key}, which resolves inside the package, and the generated view is in the application. A class without the method throws View [cms::blocks.team_roster] not found the first time an editor places the block. The key and the view name are spelled differently: the key is snake_case because blocks are stored under it, and the view name is kebab-case because Blade file names are kebab-case.

The generated view merges $cmsAttrs onto its root element:

resources/views/cms/blocks/team-roster.blade.php Blade

<section {{ $cmsAttrs->class(['cms-block', 'cms-block--team-roster']) }}>
    <h2 class="cms-block__heading">{{ $heading }}</h2>
</section>

Registration and discovery

A class in app/Cms/Blocks is registered as a block as soon as it exists. There is no manifest and no cache to rebuild.

config/cms.php PHP

'blocks' => [
    'discover' => ['App\\Cms\\Blocks' => null],
    'livewire' => ['blocks' => null],
    'register' => [],
    'disable' => [],
    'override' => [],
],
The default block registration settings.

discover

discover maps a namespace to a directory. A null directory is derived from the namespace: the service provider removes 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:

PHP

'discover' => [
    'App\\Cms\\Blocks' => null,                                  // app/Cms/Blocks
    'Acme\\Marketing\\Blocks' => base_path('modules/marketing/Blocks'),
],

The scan has these properties:

  • The scan is recursive. app/Cms/Blocks/Marketing/TeamRoster.php is found, and its class must be App\Cms\Blocks\Marketing\TeamRoster; the class name is rebuilt from the file's path relative to the scanned directory. Layout discovery is not recursive.
  • Only non-abstract subclasses of Block are registered, so an abstract class BaseCardBlock can sit in the same directory.
  • A class whose namespace does not match its path is skipped without an error. Discovery calls is_subclass_of($class, Block::class), which autoloads the class. A class that does not autoload is not treated as a block, and the block does not appear in the block picker. When a new block is missing from the picker, check its namespace against its directory first.
  • A missing directory is skipped, so a namespace whose directory does not exist yet can be listed without an error.

register

register takes explicit class names, for blocks in directories that discovery does not scan:

PHP

'register' => [
    App\Cms\Blocks\TeamRoster::class,
    Acme\Shop\Cms\ProductGrid::class,
],

Registration order and key collisions

FilamentCmsServiceProvider::registerBlocks() registers blocks in this order:

  1. The package's built-in blocks. The snippet block is registered only when cms.snippets.enabled is true, and the form block only when cms.forms.enabled is true.
  2. The classes listed in cms.blocks.register.
  3. The classes found by cms.blocks.discover.
  4. The single-file Livewire blocks found by cms.blocks.livewire.

The registry is keyed by key(), and a later registration replaces an earlier one with the same key. A class in the application that returns 'hero' from key() therefore replaces the built-in Hero block without a config entry. Two blocks with the same key do not raise an error; the later one replaces the earlier one. Check the package's src/Blocks directory before choosing a key.

Two config keys are applied inside BlockRegistry::put(), during registration, so they also affect the built-in blocks:

  • cms.blocks.disable is checked before the instance is stored, so a disabled block never enters the registry.
  • cms.blocks.override is resolved in place of the original class, so ['hero' => OurHero::class] takes effect even though Hero is registered before the application's config is read.

Block instances are resolved through the container (app($class)), so a block can inject dependencies through its constructor. The registry holds one instance per key for the whole request, and every occurrence of the block on the page shares it. Keep constructors inexpensive, and store no per-block state on the instance.

Content types are registered by the same mechanism: cms.content_types.discover and register, the same namespace-to-directory map, and the same rule that a later registration replaces an earlier one. A content type whose key() is page replaces the built-in Page type.

The minimum

A block needs three methods, key(), label() and schema(), which are the contract's abstract methods, and a view. Every other method has a working default.

app/Cms/Blocks/Quote.php PHP

<?php

declare(strict_types=1);

namespace App\Cms\Blocks;

use Filament\Forms\Components\TextInput;
use Mcg\FilamentCms\Contracts\Block;

class Quote extends Block
{
    public function key(): string
    {
        return 'quote';
    }

    public function label(): string
    {
        return 'Quote';
    }

    public function schema(): array
    {
        return [TextInput::make('body')->required()];
    }
}

Every method

The tables below list every method of Mcg\FilamentCms\Contracts\Block, grouped as the contract groups them, with the value each method returns by default and the case that calls for overriding it. Only key(), label() and schema() are abstract. Every other method has a working default.

Identity

These methods set the key a block is stored under and how the block is presented in the editor's block picker.

Method Default Override when
key() Abstract Always. The stable storage identifier. Renaming it orphans every stored block of this type.
label() Abstract Always. Shown in the block picker.
description() null The block needs a hint under its label.
icon() heroicon-o-square-3-stack-3d The block needs a distinct icon.
category() CATEGORY_LAYOUT The block belongs in another group on the Blocks & layouts screen: Layout, Media, Text, Interactive or Data. The block picker draws no category headings and lists blocks alphabetically by label.
preview() null Reserved for a future block-library browser. Nothing renders it.

Editing

The form an editor is given, the values a block starts with, and the shape its data is held to when saved.

Method Default Override when
schema() Abstract Always. Returns the Filament components for the editor.
defaults() [] A field needs a value when it is absent from stored data.
rules() [] Override it for every block. See Validation.
itemLabel() The first filled field of heading, title, label, question, author, caption and name, shortened to 40 characters The distinguishing field has another name, or no field distinguishes one instance from another and the method should return null.
badge() A count for a container ("3 blocks") or a repeater ("3 Cards"), otherwise null The count shown on the block's collapsed row reads badly.
repeatingFields() Fields that are arrays in defaults() and appear as field.* in rules() The inferred list of repeaters is wrong for the block.
commonFields() Hide, Width, Alignment, CSS classes, Anchor, Accessible name, Landmark Rarely, and only to append fields.
form() schema() plus commonFields(), in three tabs Rarely. All three form renderers call it. See The form an editor sees.
commonDefaults() The seven common field keys and their defaults Rarely.
cast() Merges commonDefaults(), then defaults(), underneath the stored data, then migrates older width values An older stored shape has to be brought forward.

Placement

Where the block may be added, how many times, and by whom.

Method Default Override when
contexts() null (any content type) The block applies only to certain content types.
maxPerVersion() null At most N instances may appear on a page. VersionManager enforces the limit on save, counting nested blocks, so it applies to every write path and not only to the panel.
allowedIn() Derived from contexts() Rarely. Override contexts() instead.
authorize() true Placing the block should require a permission.
childBlockPaths() [] The block holds other blocks. See Container blocks.
nestable() true, or false when childBlockPaths() is not empty The block is page-level structure, such as a page heading, and should not be placed inside a container.
inlineEditable() true The block's schema() does not render outside a Filament panel. Returning false removes the block's edit pencil on the front end; the block still renders and can still be dragged. See Editing on the page.

Rendering

Which template the block renders through, and which runtime that template needs.

Method Default Override when
view() cms::blocks.{key} The view is outside the package. This applies to every generated block.
isLivewire() false The block needs interactivity after the page has loaded.
livewireComponent() null Paired with isLivewire().
needsAlpine() Returns isLivewire() The view uses x-data without being a Livewire component. The method receives the block's data, so the answer can depend on how an editor filled the block in.
withView() Returns the data unchanged A value has to be resolved at render time.

Cross-cutting

The caching, media, embedding and search subsystems call these methods on every block. The sections after the tables describe needsAlpine(), contentIdsFrom(), mediaIdsFrom(), isCacheable() and searchableText() in detail.

Method Default Override when
isCacheable() true The output differs between requests. Returning false keeps every page holding the block out of the page cache. VersionCacheability enforces it.
embedsForm() false The block renders a form only when an editor selects one. See isCacheable().
cacheKeyContribution() null The output varies by something outside the version.
mediaIdsFrom() [] The block references media, so that editing an asset invalidates the pages that use it.
contentIdsFrom() [] The block embeds or links to other content. Omitting it leaves cached pages stale.
searchableText() Empty string The block holds text that should be indexed for search.

The form an editor sees

Block::form() returns the complete editing form for a block, and all three renderers call it: ContentForm for the page's block builder, ChildBlocks for a container's child blocks, and InlineBlockEditor::fieldsFor() for the front-end slide-over editor. None of the three composes its own field list, so the three forms cannot diverge.

The form has three tabs, divided by the task an editor is doing rather than by where a field was declared:

Tab Holds From
Content What the block says schema()
Layout Where it sits: Width, Alignment, CSS classes commonFields(), the fields named in Block::LAYOUT_FIELDS
Configuration Set once: Hide, Anchor, Accessible name, Landmark commonFields(), every other field

A block author writes schema() and the form receives the tabs. A host application that overrides commonFields() to append a field gets that field on the Configuration tab without referring to tabs, because the partition is by field name and an unnamed field goes to Configuration. A tab with no fields is omitted rather than shown empty.

A field on an inactive tab is still revealed when it fails validation. On a failed save, Filament's handleFormValidationError walks up the DOM from each [data-validation-error] element and dispatches expand at every ancestor. Filament's Tab component listens for expand, so the tab holding an invalid field opens and the field is scrolled into view.

cast() and withView()

cast() brings stored data forward to the field shape the block expects. withView() converts that stored shape into the variables the view reads. Both run on every render, cast() first. cast() also runs before validation on save.

cast() merges commonDefaults() and then defaults() underneath the stored data, so stored values take precedence. This keeps older versions rendering: a block that gains a field still renders versions saved before the field existed. An override must call the parent and must not overwrite stored values with defaults.

PHP

public function cast(array $data): array
{
    $data = parent::cast($data);

    // v1 stored a single `image_id`; v2 stores `images` as an array.
    if (array_key_exists('image_id', $data)) {
        $data['images'] = array_filter([$data['image_id'], ...($data['images'] ?? [])]);
        unset($data['image_id']);
    }

    return $data;
}

withView() converts the stored shape into the shape the view needs. The Image block stores a media id and resolves it to a media model only at render time:

PHP

public function withView(array $data): array
{
    return [
        ...$data,
        'media' => app(MediaResolver::class)->find($data['media_id'] ?? null),
    ];
}

A deleted asset resolves to null rather than throwing, and the image view renders nothing for it. Keep withView() inexpensive: it runs once per block per render on every page request that misses the page cache.

Fields you get without declaring them

Block::form() appends commonFields() to every block's schema, so every block has seven fields it does not declare. The labels are from resources/lang/en/cms.php (cms.blocks.common).

  • hidden, labeled Hide, takes the block off the page without deleting it. The block, its image and its wording stay in the version where the editor left them, so a promotion that runs three times a year is switched on and off rather than rebuilt each time or left live with its text removed.
  • width, labeled Width, takes one of four presets: normal, narrow, wide, bleed. The measurements are in Width presets.
  • alignment, labeled Alignment, takes left, center, right, or null (shown as “Default — follow the container”). It emits the class cms-block--align-{value}, and null emits no class, so the field was added to every block without changing how any stored page renders. The CSS sets text-align, which inherits, so a container's alignment applies to its children and a child's own alignment overrides its container's. Boxes narrower than the block also receive margin-inline: media, and .cms-block__body, which carries its own --cms-measure. Two kinds of box are excluded: .cms-block__text, which is a grid child in content_section, and nested .cms-block elements, which the width system already positions.
  • custom_classes, labeled CSS classes, is a TagsInput. The classes are applied to the block's outermost element.
  • anchor_name, labeled Anchor, is a fragment id validated against ^[A-Za-z][A-Za-z0-9_-]*$.
  • aria_label, labeled Accessible name, is announced by screen readers in place of the block's own text. Its maximum length is 120 characters.
  • aria_role, labeled Landmark, takes one value from an allow-list: region, complementary, navigation, contentinfo, search. A stored value outside the list is dropped at render rather than emitted, because an incorrect landmark role misreports the page structure to assistive technology. A stored alignment outside its three values is dropped in the same way.

Six of the seven fields, every field except hidden, reach the view as $cmsAttrs, a ComponentAttributeBag. The one $cmsAttrs->class([...]) call on the root element adds the width class (cms-block--narrow, --wide or --bleed; normal adds none), the alignment class, the custom classes, the anchor id, the aria-label attribute and the role attribute. Do not declare fields that duplicate these seven, and do not reimplement them in the view.

The seventh field, hidden, never reaches the view. The block dispatcher view resources/views/components/block.blade.php checks it first and renders nothing for a hidden block, so a block view needs no code for it. The check reads the raw stored data rather than the cast data, because a block whose type is no longer registered is never cast. Reading the cast data would make a block an editor had hidden reappear when the plugin providing its type was uninstalled.

Code that walks a page's block list for a purpose other than rendering has to skip hidden blocks itself. These methods in the package do so: VersionManager::searchTextFor(), Headings::from(), Citations::countOn(), Citations::markersOn() and FrontendRuntime::needsAlpine(). A hidden block contributes no search text, does not appear in a table of contents, consumes no citation number, and does not cause a page to load JavaScript. Use the same check in your own code:

PHP

use Mcg\FilamentCms\Contracts\Block;

if (Block::isHidden($block['data'] ?? [])) {
    continue;
}

Override commonFields() only to append a field, and include the parent's fields:

PHP

public function commonFields(): array
{
    return [...parent::commonFields(), Toggle::make('print_hidden')];
}

Validation

rules() returns standard Laravel validation rules keyed by field name. The rules run inside VersionManager::save(), so an invalid block stops the save and throws BlockValidationException, which names the block and its position. An invalid block cannot reach the front end, because it is never stored. Validation covers the whole block tree, so a block inside a container is validated against its own rules.

PHP

public function rules(): array
{
    return [
        'heading' => ['nullable', 'string', 'max:255'],
        'tone' => ['required', 'in:info,tip,warning,danger'],
        'rows' => ['array', 'max:20'],
        'rows.*.label' => ['required', 'string'],
    ];
}

Blocks of an unknown type are exempt. VersionManager stores them unchanged and skips their validation, so disabling a block type does not destroy content that re-enabling the type would restore.

Block field contracts

The MCP server also reads rules(). The tool cms_describe_block translates a block's rules() and defaults() into a machine-readable contract that tells an AI client how to write the block. The describer (BlockSchemaDescriber) never inspects schema(), because schema() returns live Filament components whose options are often closures that query the database. This has four consequences for a block author:

  1. Write rules even where the form already constrains the field. A Select with three options and no in: rule accepts any value a client sends. The same gap exists on the Filament path, because VersionManager is the only validator on either path, but an MCP client reaches it far more easily than a person using the form.
  2. A block with no rules() receives an inferred contract. The describer takes the field types from the PHP types of the defaults() values, marks every field optional, and reports "contract": "inferred" so the client knows the types are guessed. Every core block declares rules.
  3. Name media fields by convention. A field named media_id, images or logos, or any field name ending in _media_id, is annotated "references": "media". A field named photo is not. logos is on the list only because the logo_wall block was written before the convention was settled and its field cannot be renamed without breaking stored content. Do not add new exceptions. See Media.
  4. Name content fields by convention. A field named content_id, root_id, parent_id or snippet_id, or any field name ending in _content_id, is annotated "references": "content", which tells the client to send a content id from cms_list_content.

The describer understands one level of *: columns.*.heading becomes an item field of columns. A more deeply nested rule is passed through verbatim in the descriptor's rules key and omitted from the translated field list.

needsAlpine()

A page containing at least one block whose needsAlpine() returns true receives @livewireScripts, which is the source of Alpine in this stack. Returning true is what makes x-data work in a block's view. Without it, the x-data directive does nothing and no error is raised.

A page with no such block loads no JavaScript. The check is made per page rather than for the whole site, so that pages of prose do not load a front-end runtime they do not use.

isLivewire() implies needsAlpine(). Override needsAlpine() alone for a plain Blade view that uses x-data.

The method receives the block's stored data, because whether a block needs the runtime can depend on how an editor filled it in. The parameter has a default value, so an override that ignores it still satisfies the signature. Hero::needsAlpine() returns true only when the hero holds more than one slide:

PHP

// Hero::needsAlpine(): one slide is static markup; two or more slides need Alpine.
public function needsAlpine(array $data = []): bool
{
    return count((array) ($data['slides'] ?? [])) > 1;
}

The package's hero and carousel blocks use every hook described above. hero is also the package's example of changing a block's stored shape without breaking published versions.

One hero block for any number of slides

hero is the banner at the top of a page, and it holds a repeater of slides. The package previously had a separate hero_carousel block with the same fields. Two blocks with the same fields required an editor to choose between them before knowing how many slides the banner would need, and changing that choice meant deleting the block and rebuilding it, so both are now one block.

The number of slides changes only the rendering. One slide renders as static markup under an <h1>. Two or more slides render as a carousel of <h2> headings with the Alpine that drives it, arrows, dots, and autoplay that stops for a visitor whose system requests reduced motion. The needsAlpine() override above implements this, so a page carrying a single-slide hero loads no JavaScript for it. maxPerVersion() returns 1, so a page holds at most one hero regardless of its slide count.

Each slide has its own heading, subheading, background image, alignment, call to action and text tone. Tone is set per slide because each slide has its own photograph: a slide over a bright sky and a slide over a dark interior need opposite text colors, and a single block-level setting would make one of them unreadable. The control offers light or dark, and it changes the scrim together with the text color, because dark text over a scrim that still darkens the image is as unreadable as the original combination. The control appears only once the slide has a background image. Without an image the hero draws its own background wash and the text follows the theme, so no tone applies.

Bringing the old shape forward

Every hero saved before slides existed stored its heading, subheading, background, alignment and call to action as flat fields on the block. cast() reads those fields as the first and only slide instead of migrating the database. Block data is a JSON value inside an immutable version, so a database migration would have to rewrite every version ever published.

The same upgrade also runs in mediaIdsFrom() and searchableText(), because those methods receive raw stored data rather than cast data. Without it, a hero saved before slides existed would return no media ids, and the page would lose the reference that tells the cache it depends on that image. The upgrade is triggered by any of the old fields rather than by the heading alone, because a hero that holds only a background image is valid.

The old key still renders. hero_carousel is still registered, extends Hero, and returns [] from contexts(), so BlockRegistry::get() finds it for rendering and BlockRegistry::for() never offers it in the block picker. Placement and rendering are separate lookups, and only placement is switched off. Deleting the class would make every version holding the hero_carousel key treat the block as an unknown type and remove its content from the page.

carousel is a series of images with text over each one. It can be placed anywhere in a page, any number of times, and its image is the content rather than a background behind other content. Typical uses are a product tour, a case study, or a gallery with a description for each image.

carousel reuses the hero's carousel code: the same stacked slides, arrows, dots and reduced-motion rule. Only the content of a slide differs: an image, a heading, a rich text body and a link. The image is required, unlike the hero's background, because the optional thumbnail strip is built from the slide images and a slide without one would leave an empty position in the strip. The caption sits below the frame, out of view, and slides up over the image when the pointer or keyboard focus reaches the slide, taking only the height its content needs. On a touch screen, which has no hover state, the caption stays visible.

carousel differs from hero in two further ways:

  1. Autoplay is off by default ('autoplay' => false), where the hero's is on. A carousel in the body of a page is content a visitor may be partway through reading, and advancing it automatically would move it while they read.
  2. The block has no alt-text field. Alt text is stored with the asset in the media library, so it is written once and applies everywhere the asset appears.

A slide whose image has been deleted from the media library is removed in withView() rather than rendered as a gap, and the thumbnail strip reads the same list, so it stays consistent.

contentIdsFrom() and mediaIdsFrom()

A block that stores a content id must return it from contentIdsFrom():

PHP

public function contentIdsFrom(array $data): array
{
    return filled($data['snippet_id'] ?? null) ? [(string) $data['snippet_id']] : [];
}

No error is reported when the method is missing. Two mechanisms fail instead:

  1. Cache invalidation. Republishing embedded content invalidates the pages that display it. A block that omits contentIdsFrom() renders correctly and then serves stale HTML until the page's cache entry is cleared by another change.
  2. Cacheability propagation. VersionCacheability follows these ids into the embedded content's own blocks, so a form inside a footer snippet marks every page using that footer as uncacheable.

mediaIdsFrom() does the same for Spatie media ids, so that editing an asset invalidates the pages that use it. Both methods feed the cache mechanism described in Caching.

isCacheable()

Returning false from isCacheable() prevents the whole page from being stored in the shared page cache, including any page that embeds the block through a snippet. isCacheable() must return false in two cases:

  1. The markup contains per-request data. A CSRF token, a Livewire snapshot or a signed timestamp is valid for one visitor and fails for every visitor served the cached copy afterwards. In this package the form block is this case: it renders a Livewire component carrying a snapshot and a signed submission timestamp.
  2. The output depends on data outside this version. The related block lists pages that share tags with the current page, so its output changes when a different page is tagged, while this page's version does not change.

When the outside data can be identified, tag the page instead of returning false. The page_list and series_nav blocks read the children of one parent page. ChildPages::for() records the parent with PageCacheTags::add() during render, and changing a child page clears that tag, so both blocks remain cacheable. See Caching.

A block that carries a form only when an editor selects one should leave isCacheable() returning true and override embedsForm(array $data) instead, which receives the block's stored data. isCacheable() takes no arguments, so returning false from it would make every page carrying the block uncacheable, including pages where the block has no form.

The related and navigation blocks return false for the second reason. contentIdsFrom() can only reference pages that exist when the block is saved. It cannot cover a page created later that the block should list, because there is no stored reference to invalidate. Any block that reads the page tree rather than its own fields is in the same position, unless it tags the page the way page_list and series_nav do.

The page_list and navigation blocks both read the page tree. They differ in depth:

  • page_list lists one level: the published children of one page, with dates and summaries. A blog index is a page_list.
  • navigation is recursive: a page and its descendants to a configured depth (the Levels field), rendered as a menu. Its entries are not authored. A new page appears in the menu once it is published, and moving a page in the page tree moves it in the menu.

The navigation block marks the current page with aria-current="page" and marks every ancestor of the current page as active, so a side rail shows where the reader is. With Only open the current section switched on, a branch is rendered only when the current page is inside it. The other branches are omitted from the markup rather than hidden with CSS: the block has no client-side toggle, so a hidden branch would remain in the accessibility tree with no way to open it.

Show parent page link renders the page the tree starts from (the Start from field) above its children, so a reader can return to the section's own page, which usually holds its introduction. The link is placed outside the list rather than as its first item, because it is the parent of the listed pages and nesting it would mark it as their sibling. The toggle is shown only after a Start from page is chosen, because a tree that starts at the top of the site has no parent page to link.

NavigationBuilder::tree() provides the same data for custom markup. It takes a root content id, a depth (default 3) and the current path, and returns nested arrays with the keys id, label, path, url, is_current, is_active and children. A label is the page's nav_label attribute when it is set, and its title otherwise, so a page with a long title can have a short menu entry without being renamed.

The builder fetches every published page in scope with one query and nests them in memory. A recursive query would cost one round trip per level and save nothing, because the whole set is needed anyway: whether a page is active depends on whether any of its descendants, at any depth, is the current page.

searchableText()

Each block decides which of its fields are searchable text. At save, the results for every block are joined into the version's search_text column, together with the content type's searchableAttributes().

PHP

public function searchableText(array $data): string
{
    return static::plainText([$data['heading'] ?? null, $data['body'] ?? null]);
}

plainText() is a protected static helper on Block. It strips tags, flattens nested arrays to their text values and collapses whitespace. Use it instead of a (string) cast: the live form state of a rich text editor can be a document array rather than a string, and casting an array to a string throws.

Return an empty string for text the block does not own. The snippet block returns an empty string because the snippet indexes its own text, and indexing it again would weight every page that embeds the snippet by text that belongs to the snippet. The related block indexes only its heading, because the pages it links to index themselves.

After changing a block's searchableText(), rebuild the stored search text in a terminal at the site's root:

Shell

php artisan cms:reindex          # versions with no search text
php artisan cms:reindex --all    # every version

Container blocks

A container block holds other blocks rather than only its own fields, so each tab, column or panel can hold different blocks. The package's containers are section, tabs, columns and accordion. No other shipped block is a container.

A container declares its child lists with one method:

PHP

public function childBlockPaths(): array
{
    return ['tabs.*.blocks'];
}

Each entry is a data path to one child list, with * standing for a repeater row index. ['blocks'] declares one flat list, as section does. ['tabs.*.blocks'] declares one list per row, as tabs does. The field in schema() must have the same name as the path:

PHP

use Mcg\FilamentCms\Support\ChildBlocks;

public function schema(): array
{
    return [
        TextInput::make('heading')->maxLength(255),

        ChildBlocks::field('blocks'),
    ];
}

ChildBlocks::field() builds the child block picker. It offers the blocks available to the page's content type, excluding every block whose nestable() returns false, which includes every container.

The container's view renders its children through the same component the page uses:

Blade

@foreach ($blocks ?? [] as $child)
    <x-cms::block :type="$child['type']" :data="$child['data'] ?? []" />
@endforeach

cms:make-block writes all three parts. In the guided path, answer Other blocks — a band, a set of tabs, columns to What does it hold?, then answer How many lists of blocks? with One — a band or a wrapper around a group of blocks or One per row — tabs, columns, accordion entries.

What a container does not do

A container does not report its children's media, links or text. The traversal collects each child block on its own, so a mediaIdsFrom() on the container that returned its children's ids would add every id to the version's references twice. Report only what the container itself holds, such as a background image or a heading.

A container does not validate its children. Each child is validated against its own rules(). A container rule that named a child's field would assume the contract of every block an editor might place in the container.

Nesting depth

Containers hold ordinary blocks. A container cannot hold a container, and BlockTree::MAX_DEPTH (1) enforces that limit regardless of what any block's nestable() returns.

A block prevents itself from being placed in a container by returning false from nestable():

PHP

public function nestable(): bool
{
    return false;
}

nestable() returns true by default, so a host application's own blocks can be placed inside a container without changes. The package's own non-nestable blocks are page_heading, breadcrumbs, table_of_contents and series_nav, which are page-level structure, and snippet, because a snippet's blocks can include a container, which would place a container inside a container with the snippet in between.

Addressing a nested block over MCP

A block list is a tree, so every block has a path. 2.tabs.0.blocks.1 is the second block in the first tab of the third block on the page. Every cms_get_content response includes block_paths, and cms_append_block accepts into with a container slot path. See The MCP server.

Livewire blocks

A block that has to respond to input after the page has loaded is rendered by a Livewire component rather than a Blade include. A Livewire block can be declared in one of two ways, and both produce the same registered block.

Single-file blocks

In a terminal at the site's root, php artisan cms:make-block Feature --livewire writes one file, resources/views/livewire/blocks/feature.blade.php, which holds the block definition and the markup together. There is no class in app/Cms/Blocks, no view under resources/views/cms/blocks, and no registration step:

resources/views/livewire/blocks/feature.blade.php PHP

new class extends Component implements DefinesBlock
{
    public string $heading = '';

    public static function block(): BlockDefinition
    {
        return BlockDefinition::make('feature')
            ->label('Feature')
            ->schema([TextInput::make('heading')->required()])
            ->rules(['heading' => ['required', 'string', 'max:120']]);
    }
};

block() is static because the panel builds the block's form without rendering the component, so no component instance exists at that point. BlockDefinition holds the values a Block subclass would return from its overridden methods. SingleFileBlock converts the definition into a Block at registration, so the editor, the validator, the renderer and the search indexer treat it the same as a class-based block. Every value the definition does not set falls back to Block's own default.

Four of the definition's methods take a closure rather than a value, because their answer depends on the block's stored data. Each is named after the Block method it stands in for:

Definition method What the closure returns
mediaIdsFrom(fn (array $data) => …) The media ids the block references, so that editing a file invalidates the pages showing it and the media delete guard protects the file
contentIdsFrom(fn (array $data) => …) The content ids the block links to or embeds, so that republishing the target invalidates the pages pointing at it
searchableText(fn (array $data) => …) The block's text for the search index, for cases where naming fields with searchable() is not enough, such as a repeater whose rows are arrays
itemLabel(fn (array $data) => …) The text that distinguishes one instance from another in the builder's collapsed list

Set mediaIdsFrom() and contentIdsFrom() whenever the block stores a media id or a content id. Both failures are silent. A block that reports no media looks like a block with no media, so the media usage report shows the file as unused, and deleting the file removes the image from a live page. A definition that sets none of the four closures falls back to Block's defaults, which is the behavior single-file blocks written before these methods existed rely on.

cms:make-block --livewire --interactive writes each of the four closures when the fields described in the guided path call for it: mediaIdsFrom() when an image field is chosen, contentIdsFrom() when a link field is chosen, searchableText() for a repeater or for a block with a heading or body field, and itemLabel() for a repeater with a heading field.

Discovery of single-file blocks

BlockRegistry::discoverLivewire() is the single-file counterpart of cms.blocks.discover. It walks resources/views/livewire/blocks, asks Livewire's component factory which class each *.blade.php file compiles to, and registers each class that implements DefinesBlock. A file that fails to compile is skipped, so a single broken block file removes that block from the editor and does not stop the panel from loading.

Discovery scans a subdirectory of resources/views/livewire rather than the whole directory, because discovery compiles every file it scans. Scanning every component in the host application would compile all of them at boot to find that most are not blocks. Livewire caches compiled classes on disk, so after the first run discovery costs one stat() call per file.

Discovery is configured by cms.blocks.livewire, an array keyed by component name prefix and valued with a directory; a null directory is derived under resources/views/livewire, so the default ['blocks' => null] scans resources/views/livewire/blocks. The service provider reads the key with a default value rather than relying on the published config file, because Laravel's mergeConfigFrom merges only the top level: a host application that published config/cms.php before the key existed has its own blocks array, which replaces the package's array entirely. To switch single-file discovery off, set 'livewire' => [].

resources/views/livewire is one of Livewire's two default component_locations, so the component names resolve with no configuration and the blocks subdirectory becomes part of each name: the example above is blocks.feature. A component placed under resources/views/cms/blocks would not be found by Livewire.

Class-based Livewire blocks

A class-based block can render a Livewire component instead of a Blade view: isLivewire() returns true, and livewireComponent() returns the component name. view() is not used on this path. Use a class in three cases:

  1. The schema is large enough that a long block() method inside a Blade file is harder to read than a class.
  2. The block needs a method that BlockDefinition does not carry, such as cast(), withView(), needsAlpine() or authorize().
  3. The definition has to be visible to static analysis. Nothing in the single-file shape is reachable by PHPStan or by an IDE's “find usages”.

What livewireComponent() returns

livewireComponent() returns an ordinary Livewire component name. The CMS adds no prefix or namespace: the string is passed directly to @livewire(), so it is the same name a Blade template would use, and Livewire resolves it against livewire.class_namespace (App\Livewire by default) and livewire.component_locations (resources/views/components and resources/views/livewire by default).

The component's file The component name
app/Livewire/AvailabilityChecker.php availability-checker
resources/views/livewire/blocks/availability-checker.blade.php blocks.availability-checker
app/Livewire/Cms/Blocks/AvailabilityChecker.php cms.blocks.availability-checker

The correct name is determined by the file the component is in. A wrong name raises an error: Livewire throws ComponentNotFoundException naming the string it could not resolve.

A discovered single-file block does not set the name by hand. discoverLivewire() builds the name from the configured prefix and the file's path under the scanned directory, and passes it to SingleFileBlock, which returns it. The two single-file blocks in the package repository's demo application, resources/views/livewire/blocks/feature.blade.php and resources/views/livewire/blocks/seat-estimator.blade.php, therefore return blocks.feature and blocks.seat-estimator.

Rules for both Livewire shapes

The block dispatcher (resources/views/components/block.blade.php) checks isLivewire() and emits @livewire() instead of @include, whichever shape declared the block. Two rules then differ from the Blade path.

The shared fields are applied to a wrapper element around the component, not inside it. A Blade view merges $cmsAttrs onto its own root element. A Livewire component receives none of the shared fields and needs no code for them. The dispatcher emits a <div> carrying cms-block, the cms-block--{key} modifier (the key with underscores replaced by hyphens), the width class, the alignment class, the custom classes, the anchor id, aria-label and role, and mounts the component inside it.

Livewire could carry these attributes itself: a mount parameter the component does not declare reaches the component's view as $attributes, and SupportHtmlAttributeForwarding keeps it across updates in the snapshot memo. The attributes would reach the markup only if the component's view merged {{ $attributes }}, so a component that omitted the merge would silently lose its width preset. The wrapper requires nothing from the block author. It is also stable, because Livewire replaces its own root element and everything below it on every update and never modifies a parent element.

As a consequence, a Livewire block's own markup must not declare cms-block. If it does, the page receives two nested elements that each carry the block's margins. This rule is the opposite of the rule for a class-based Blade block, whose view must merge $cmsAttrs onto its own root element.

Declare a public property for every field the block stores. The dispatcher's livewireData() removes the seven shared fields (the keys of commonDefaults()) before mounting the component. Livewire passes every remaining parameter the component does not declare to the component's view as $attributes. On a component whose root element merges {{ $attributes }}, an undeclared field appears in the markup as an attribute nobody wrote, and an array field such as custom_classes throws trim(): Argument #1 ($string) must be of type string, array given from inside a compiled view, naming neither the block nor the field. Both failures were reproduced against Livewire 4.3.4.

The dispatcher gives each Livewire block a key made of the block type and a per-request sequence number (View\Components\Block::livewireKey()). An earlier version hashed the view data, so two copies of the same block with identical data received the same key and rendered as one component.

Building a Livewire block walks through one block from generation to placement.

The page cache stores rendered HTML, so a Livewire block that renders per-user or per-request content is cached with the rest of the page. Keep the component's output identical for every anonymous visitor, or declare it uncacheable: ->cacheable(false) on the definition, or false from isCacheable() on a class. Authenticated requests always bypass the page cache (CachesCmsPages skips any request with a user), so an administrator never sees a stale page; the risk is a public visitor being served the render produced for another visitor.

Testing

The package ships the Mcg\FilamentCms\Testing\InteractsWithBlocks trait, which provides renderBlock(), blockPassesValidation() and blockValidationErrors() to a test case. The example below also uses a global renderBlock() function and the expectations toPassValidation() and toFailValidation(). Those three wrappers are defined in the package's own tests/Pest.php, which is not distributed, so a host application has to add them to its own tests/Pest.php. Testing your work gives the setup and the wrapper code.

PHP

it('renders its body', function () {
    expect(renderBlock(new Quote, ['body' => 'Hello']))->toContain('Hello');
});

it('validates', function () {
    expect(new Quote)->toPassValidation(['body' => 'Hello'])
        ->and(new Quote)->toFailValidation(['body' => '']);
});

renderBlock() registers the block and renders it through the <x-cms::block> dispatcher, so a test exercises cast(), withView() and the view together.

Restricting placement

contexts() restricts a block to named content types, and authorize() restricts it to users who hold a permission. By default both are unrestricted: every block is available in every content type to every user who can reach the panel.

PHP

// Only in these content types. null means anywhere.
public function contexts(): ?array
{
    return ['page', 'landing_page'];
}

// Consulted by BlockRegistry::for(), so a denied block does not reach the
// picker. The MCP write path applies the same check.
public function authorize(?Authenticatable $user = null): bool
{
    return $user?->can('cms.place-team-roster') ?? false;
}

The block picker offers the intersection of the block's contexts() and the content type's own allowedBlocks().

To restrict the html block independently of panel access:

  1. In config/cms.php, name a Gate ability in blocks.raw_html_ability. The default is null, which leaves the block available to every panel user.
  2. In a service provider's boot() method, define the ability with Gate::define().

PHP

// config/cms.php
'blocks' => ['raw_html_ability' => 'cms.raw-html'],

// A service provider
Gate::define('cms.raw-html', fn (User $user) => $user->is_owner);

To remove the block entirely, add it to disable instead: 'disable' => ['html']. In both cases existing content that uses the block is preserved and renders again when the block is made available again.

Over MCP, the html block is gated rather than refused (mcp.blocks.gated defaults to ['html']): a call that places it must include confirm_raw_html: true. See The MCP server.

Overriding and replacing a block

Any block the package ships can be changed without forking the package. The four options below change, in order, the markup the block renders, the view it renders through, the class behind its key, and whether it is offered to editors at all. Each option leaves content already stored under the block's key intact. Use the first option that does what is needed. Try a CSS change in the skin before any of them: a restyle continues to work after a package upgrade, and a copied view does not receive the upgrade's changes; see Restyling instead of overriding.

1. Replace the Blade view

This option changes the markup and nothing else. The block keeps its key, its fields and its validation, so every page already using it renders through the new view with no change to the content.

Laravel resolves views in resources/views/vendor/cms/ before the package's own, so creating resources/views/vendor/cms/blocks/hero.blade.php is enough. No config entry or publish step is needed.

To start from the package's markup rather than an empty file, run this in a terminal at the site's root:

Shell

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

2. Point the block at a different view

Override view() on the block class to load its template from another location.

3. Replace the class

cms.blocks.override substitutes the class behind a key, so stored content keeps working while both the schema and the rendering change:

config/cms.php PHP

'blocks' => [
    'override' => ['hero' => App\Cms\Blocks\OurHero::class],
],

The substitution happens inside BlockRegistry's registration (BlockRegistry::put()), so it applies to the package's own registration and nothing has to be unregistered.

4. Disable the block

'blocks' => ['disable' => ['html']] removes the block from the registry and therefore from the picker. Existing content that uses the block is preserved and renders again when the block is re-enabled. While the block is disabled it renders nothing, except in the local, development and testing environments, where the dispatcher renders a dashed red “Unknown CMS block” marker in its place.

5. Register a class with the same key

As described under Registration and discovery, a block class in the application that returns an existing key() replaces that block without a config entry.

The default layout is overridden the same way: publish it with php artisan vendor:publish --tag=cms-views and edit resources/views/vendor/cms/layouts/default.blade.php.

Restyling instead of overriding

A CSS change in the skin continues to work after a package upgrade, and a copied view does not receive the upgrade's changes. The package ships minimal default styles in resources/css/cms.css. Import the file after Tailwind in the site's stylesheet:

CSS

@import 'tailwindcss';
@import '../../vendor/mcg/filament-cms/resources/css/cms.css';

The package's Blade views carry only semantic cms-* classes and no Tailwind utility classes. The utilities are applied in the stylesheet with @apply, for two reasons:

  1. @apply resolves at build time, so Tailwind never has to scan the package's views. No @source line is needed, and the styles still build when the package is installed without a symlink.
  2. The views contain only semantic classes, which keeps them readable when a site overrides them.

To change a style, redefine the rule after the import. All of cms.css is in @layer components, so a rule written after the import takes precedence by source order, and a Tailwind utility class added directly in a Blade view overrides both.

CSS

@layer components {
    .cms-block__heading { @apply font-serif text-5xl tracking-tight; }
    .cms-button         { @apply rounded-full bg-brand-600 hover:bg-brand-700; }
    .cms-block--cta     { @apply rounded-none border-y border-brand-200; }
}

To copy the whole stylesheet into the application instead, run php artisan vendor:publish --tag=cms-styles in a terminal at the site's root. The token layer behind these styles is documented in Skins and the design-token system.

The class vocabulary

Class Applies to
.cms-body, .cms-main, .cms-main--reading, .cms-main--wide Layout scaffolding
.cms-block Every block wrapper
.cms-block--{key} One per block type, for example --hero, --rich-text, --callout
.cms-block--narrow, --wide, --bleed Width presets. normal emits no class.
.cms-block--normal Never emitted by the package. Add it in a block's CSS classes field to hold the block at the normal measure on the wide, home or docs layouts.
.cms-block--align-left, --align-center, --align-right The Alignment field. An unset alignment emits no class.
.cms-block__heading, __subheading, __body, __inner, __background Block internals
.cms-prose A container filled from a RichEditor field. It gives the container's children a 1em gap. See below.
.cms-button, --secondary, --subtle Buttons
.cms-link Inline links
.cms-image, .cms-image__caption Media
.cms-grid--2, --3, --4 Image grid and columns
.cms-column, __heading, __body Columns block
.cms-spacer--small, --medium, --large, --xlarge Spacer
.cms-article__title, __lead, __meta, __body Article layout elements
.cms-header, .cms-nav__link, .cms-footer__inner Site header, navigation and footer

Containers that hold a rich text field

Tailwind's preflight sets the margin of p to zero, so in a container filled from a RichEditor field the second paragraph renders directly against the first unless a rule restores the gap. Add the class cms-prose to that container, and the container's children (paragraphs, lists and headings) are separated by 1em:

Blade

<div class="cms-quote__body cms-prose">{!! str($body ?? '')->sanitizeHtml() !!}</div>

The gap is in em, so it scales with the container's font size, including a font size a skin changes. The selector is > * + *, so the rule never adds a margin to the first or last child and cannot push the container's own edges outward.

The package's own body containers (.cms-block__body, .cms-card__body, .cms-column__body, .cms-callout__body and the others listed beside .cms-prose in cms.css) already carry the rule, so cms-prose is needed only on a container a new block introduces. The blocks that render long-form prose rather than a single field (rich_text, content_section and the accordion answer) use cms-block--rich-text instead, which also styles headings, lists, blockquotes, code and tables.