Skip to content
MCG Filament CMS Demo

Block reference

This document 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, or of one Livewire single-file component. No other file is registered, generated or compiled to add a block.

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 Design 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](#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. The --livewire flag selects between them.

Command Files created
php artisan cms:make-block PricingTable app/Cms/Blocks/PricingTable.php (key pricingtable, label Pricing Table) and resources/views/cms/blocks/pricing-table.blade.php
php artisan cms:make-block AvailabilityChecker --livewire resources/views/livewire/blocks/availability-checker.blade.php only, holding the schema, the validation and the markup together

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()). Use the --livewire shape when the block has to respond to input after the page has loaded; see Livewire blocks. The rest of this document describes the class shape. Its sections on fields, validation, styling and testing apply to both shapes.

The guided path

The guided path asks questions about the block and writes a schema, rules, defaults and markup that agree with each other. It runs in either of these cases:

  • The command is run with --interactive, in an interactive terminal.
  • The command is run with no class name. It then asks **What should the block be called?, Make it a single-file Livewire block? and Describe its fields now?** (default yes).

The first question of the guided path is What does it hold?, with three answers:

Answer Use it for
One set of fields β€” a feature, a testimonial A block with a fixed set of fields
A repeating list of the same fields β€” cards, tiers, steps A repeater: every row has the same fields
Other blocks β€” a band, a set of tabs, columns A container: each child list (one list, or one per row) holds whatever blocks an editor places in it

Choose Other blocks 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.

The generated view() method

The generated class declares its own view():

PHP

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

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.pricing_table] 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

The generated view merges $cmsAttrs onto its root element:

Blade

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

Keep the $cmsAttrs merge. It applies six of the seven fields every block receives without declaring them; see Fields you get without declaring them. A view without the merge ignores all six fields and receives none of the shared .cms-block styling. No error is raised.

Registration and discovery

A class in app/Cms/Blocks is registered as a block as soon as the file exists. There is no manifest and no cache to rebuild. The default block registration settings in config/cms.php are:

PHP

'blocks' => [
    'discover' => ['App\\Cms\\Blocks' => null],
    'livewire' => ['blocks' => null],
    'register' => [],
    'disable' => [],
    'override' => [],
],

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/PricingTable.php is found, and its class must be App\Cms\Blocks\Marketing\PricingTable; the class name is rebuilt from the file's path relative to the scanned directory. Layout discovery is not recursive; see layouts.md.
  • 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\PricingTable::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, with the same namespace-to-directory map and the same rule that a later registration replaces an earlier one; see content-types.md.

The minimum

A block needs three methods and a view. Every other method has a working default.

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 the methods of Mcg\FilamentCms\Contracts\Block, grouped as the contract groups them.

Identity

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() CATEGORYLAYOUT 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

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 or a repeater, otherwise null The count shown on the block's collapsed row reads badly. See below.
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. See Fields you get without declaring them.
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.

badge() reports, on a container, the number of child blocks it holds ("3 blocks"). On a block with exactly one repeating field it reports the row count with the field name as the noun ("3 Cards", "1 Card"). On a block with more than one repeating field it reports the total ("5 items").

Placement

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; see Editing on the page. Returning false removes the block's edit pencil on the front end. The block still renders and can still be dragged.

Rendering

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 the block was filled in.
withView() Returns the data unchanged A value has to be resolved at render time.

Cross-cutting

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() '' The block holds text that should be indexed.

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.
  • 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 Source
Content What the block says schema()
Layout Where the block sits: Width, Alignment, CSS classes commonFields(), the fields named in Block::LAYOUTFIELDS
Configuration Settings made 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.

Fields you get without declaring them

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

Field Label Behavior
hidden Hide Takes the block off the page without deleting it. Handled before the view renders, not through $cmsAttrs; see below.
width Width One of four presets: normal, narrow, wide, bleed. The measurements are in layouts.md.
alignment Alignment left, center, right, or null (shown as "Default β€” follow the container"). See below.
customclasses CSS classes A TagsInput. The classes are applied to the block's outermost element.
anchorname Anchor A fragment id, validated against ^[A-Za-z][A-Za-z0-9-]$.
arialabel Accessible name Announced by screen readers in place of the block's own text. Maximum 120 characters.
ariarole Landmark One of an allow-list: region, complementary, navigation, contentinfo, search.

alignment emits the class cms-block--align-{value}. A null value emits no class. Because null emits nothing, 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.

A stored aria_role outside the allow-list is dropped at render rather than emitted, because an incorrect landmark role misreports the page structure to assistive technology. A stored alignment outside left, center and right is dropped in the same way.

Do not declare fields of your own that duplicate these seven, and do not reimplement them in the view.

$cmsAttrs

Six of the seven fields, every field except hidden, reach the view as $cmsAttrs, a ComponentAttributeBag. Merging it onto the root element is the only step a view needs:

Blade

<section {{ $cmsAttrs->class(['cms-block', 'cms-block--pricing-table']) }}>

That call 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.

Hide

The seventh field, Hide, 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, such as a table of contents, search text or citation numbering, 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;
}

Appending to commonFields()

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

cast() and withView()

cast() and withView() 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.

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. ['nullable', 'integer'] does not say "a media id from cms_list_media". A field named media_id, images or logos, or any field name ending in _media_id, is annotated "references": "media". 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.

PHP

public function rules(): array
{
    return [
        // Annotated as a media reference; a field named `photo` would not be.
        'photo_media_id' => ['nullable', 'integer'],
    ];
}

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:

PHP

public function needsAlpine(array $data = []): bool
{
    return true;
}

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 hero and carousel blocks both return true only when they hold more than one slide: one slide is static markup, and two or more slides form a carousel that Alpine drives. A check made on the class alone would load Alpine on every page carrying a single-slide hero. The parameter has a default value, so an override that ignores it still satisfies the signature.

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 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 media ids, so that editing an asset invalidates the pages that use it. Both methods feed the cache mechanism described in caching.md.

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. See Caching.

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 '' for text the block does not own. The snippet block returns '' 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

The rest of the search feature is described in search.md.

Restricting to content types

contexts() returns the content type keys a block can be placed in:

PHP

public function contexts(): ?array
{
    return ['page', 'landing_page'];
}

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

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 mcp-server.md.

Livewire blocks

A block that has to respond to input after the page has loaded is rendered by a Livewire component. 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, run:

Shell

php artisan cms:make-block AvailabilityChecker --livewire

The command writes resources/views/livewire/blocks/availability-checker.blade.php and no other file. There is no class in app/Cms/Blocks, no view under resources/views/cms/blocks, and no registration step:

PHP

<?php

use Filament\Forms\Components\TextInput;
use Livewire\Component;
use Mcg\FilamentCms\Contracts\Block;
use Mcg\FilamentCms\Contracts\DefinesBlock;
use Mcg\FilamentCms\Support\BlockDefinition;

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

    public static function block(): BlockDefinition
    {
        return BlockDefinition::make('availability_checker')
            ->label('Availability Checker')
            ->category(Block::CATEGORY_INTERACTIVE)
            ->icon('heroicon-o-cursor-arrow-ripple')
            ->schema([
                TextInput::make('heading')->required()->maxLength(120),
            ])
            ->defaults(['heading' => ''])
            ->rules(['heading' => ['required', 'string', 'max:120']])
            ->searchable(['heading'])
            ->cacheable(true);
    }
};
?>

<div>
    <h2 class="cms-block__heading">{{ $heading }}</h2>
</div>

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; a definition that sets no description() or contexts() gets null for both.

block() is static because the panel builds the block's form without rendering the component, so no component instance exists at that point.

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 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. To switch single-file discovery off, set 'livewire' => [].

The service provider reads cms.blocks.livewire 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, so the new key would never reach it.

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.

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. 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; --livewire is a shortcut for the common case. Two methods are the whole difference, and view() is not used on this path:

PHP

public function isLivewire(): bool
{
    return true;
}

public function livewireComponent(): ?string
{
    return 'availability-checker';
}

Use a class when the schema is large enough that a long block() method inside a Blade file is harder to read than a class, or when the block needs a method that BlockDefinition does not carry, such as cast(), withView(), needsAlpine() or authorize().

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 the same way as any other name, 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 this 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(...). 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 view 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. An undeclared field therefore has an effect: on a component whose root element merges {{ $attributes }}, the field appears in the markup as an attribute nobody wrote, and an array field throws

Plain text

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.

Caution: the page cache stores rendered HTML. A Livewire block that renders per-user or per-request content is cached with the rest of the page. Keep such a component read-only and identical for every anonymous visitor, or return false from isCacheable(). 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 examples below also use 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.md 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.

Styling and theming

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. The later rule takes precedence because it is in the same layer:

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; }
}

All of the package's rules are in @layer components, so a Tailwind utility class added directly in a Blade view still overrides them.

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 design-system.md.

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-blockheading, 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-imagecaption Media
.cms-grid--2, --3, --4 Image grid and columns
.cms-column, heading, body Columns block
.cms-spacer--small, --medium, --large, --xlarge Spacer
.cms-articletitle, lead, meta, body Article layout elements
.cms-header, .cms-navlink, .cms-footerinner 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 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.

Overriding and replacing a block

The options below are ordered from the smallest change to the largest. Use a later option only when the earlier ones cannot do 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.

  1. Replace the Blade view. Laravel resolves views in resources/views/vendor/cms/ before the package's own, so creating the file is enough. No config entry or publish step is needed:

Plain text

resources/views/vendor/cms/blocks/hero.blade.php

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

Every published view stops receiving upstream changes. Keep the views you need to change and delete the rest.

  1. Point the block at a different view. Override view() on the block class to load its template from another location.
  2. 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:

PHP

// config/cms.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.

  1. Disable the block.

PHP

'blocks' => ['disable' => ['html']],

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

  1. Register a class with the same key. As described under [Registration and discovery](#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.

Restricting who can place a block

BlockRegistry::for() calls each block's authorize() with the current user, so a block that returns false is not offered in the editor's block picker. The MCP write path applies the same check, so a block a user cannot place in the panel cannot be placed over MCP either.

PHP

public function authorize(?Authenticatable $user = null): bool
{
    return $user?->can('cms.place-pricing-table') ?? false;
}

The default authorize() returns true, so every block is available to every user who can reach the panel.

The html block

The html block renders its content unescaped, so any user who can place it can run script on the published page. By default that is every user who can reach the panel. The package's HasCmsAccess trait grants panel access only to users whose is_cms_admin attribute is true, but a host application that widens canAccessPanel() to editors also widens access to the html block, and nothing reports the change.

To restrict the html block independently of panel access:

  1. In config/cms.php, name a Gate ability in blocks.raw_html_ability (default null, which leaves the block available to every panel user):

PHP

'blocks' => [
    'raw_html_ability' => 'cms.raw-html',
],
  1. In a service provider's boot() method, define the ability:

PHP

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 mcp.md.