Building a custom block
This tutorial builds one class-based block from the Artisan generator to a block placed on a published page. The finished block, Feature, renders an image beside a headline, a short paragraph and up to two buttons. The tutorial is written for a developer adding a block to a site that runs the mcg/filament-cms package.
A block can also render as a Livewire component, which is the form to use when the block has to respond to input after the page has loaded. Building a Livewire block builds the same block that way and compares the two forms. Follow this tutorial first: the block contract is the same in both forms, and only the rendering differs.
1. Generate the two files
- In a terminal at the site's root, run the generator with the block's class name in StudlyCase:
php artisan cms:make-block Feature
The command prints Block [feature] created. followed by the paths of the two files it wrote:
| Output | Value |
|---|---|
| The class | app/Cms/Blocks/Feature.php |
| The view | resources/views/cms/blocks/feature.blade.php |
| The block key | feature, the snake_case form of the class name |
| The view name | cms.blocks.feature, built from the kebab-case form of the class name |
2. Read the generated view method
- In
app/Cms/Blocks/Feature.php, find theview()method the generator wrote:
/**
* The default is `cms::blocks.{key}`, which resolves inside the package.
* This view lives in your application, so it has to say so.
*/
public function view(): string
{
return 'cms.blocks.feature';
}
Block::view() returns cms::blocks.{key} by default, which is a view inside the package's own cms:: namespace. A block's template lives in the application, so the class overrides view() to name it. If this method is deleted, the block throws a view-not-found error that names a path inside the package, a file that was never created.
Earlier versions of the generator did not write this method and printed a reminder instead. The test that claimed to cover the generator declared its own view() rather than using the generated class, so the test suite passed while every generated block failed to render.
3. Write the class
The class defines what an editor is offered, which values are valid, and what the rest of the CMS needs to know about the block.
- In
app/Cms/Blocks/Feature.php, replace the generated contents with this class:
<?php
declare(strict_types=1);
namespace App\Cms\Blocks;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Mcg\FilamentCms\Contracts\Block;
use Mcg\FilamentCms\Support\MediaOptions;
use Mcg\FilamentCms\Support\MediaResolver;
class Feature extends Block
{
public function key(): string
{
return 'feature';
}
public function label(): string
{
return 'Feature';
}
public function description(): ?string
{
return 'An image beside a headline, a short paragraph and two buttons.';
}
public function category(): string
{
return self::CATEGORY_MEDIA;
}
public function icon(): string
{
return 'heroicon-o-view-columns';
}
public function view(): string
{
return 'cms.blocks.feature';
}
/** The form an editor fills in. @return array<int, mixed> */
public function schema(): array
{
return [
// Reuses the CMS media library rather than adding an uploader.
MediaOptions::picker('media_id')
->label('Image')
->required(),
Select::make('image_position')
->label('Image position')
->options(['left' => 'Left', 'right' => 'Right'])
->default('left')
->selectablePlaceholder(false),
TextInput::make('heading')->required()->maxLength(120),
Textarea::make('description')
->rows(3)
->maxLength(400)
->columnSpanFull(),
TextInput::make('primary_label')->label('Primary button')->maxLength(40),
TextInput::make('primary_url')->label('Primary URL')
->maxLength(2048)->requiredWith('primary_label'),
TextInput::make('secondary_label')->label('Secondary button')->maxLength(40),
TextInput::make('secondary_url')->label('Secondary URL')
->maxLength(2048)->requiredWith('secondary_label'),
];
}
/** @return array<string, mixed> */
public function defaults(): array
{
return [
'media_id' => null,
'image_position' => 'left',
'heading' => '',
'description' => null,
'primary_label' => null,
'primary_url' => null,
'secondary_label' => null,
'secondary_url' => null,
];
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'media_id' => ['required', 'integer'],
'image_position' => ['required', 'in:left,right'],
'heading' => ['required', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:400'],
'primary_label' => ['nullable', 'string', 'max:40'],
'primary_url' => ['nullable', 'required_with:primary_label', 'string', 'max:2048'],
'secondary_label' => ['nullable', 'string', 'max:40'],
'secondary_url' => ['nullable', 'required_with:secondary_label', 'string', 'max:2048'],
];
}
/**
* Turn the stored id into something the view can render.
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function withView(array $data): array
{
$resolver = app(MediaResolver::class);
$media = $resolver->find($data['media_id'] ?? null);
$data['image'] = $media;
$data['alt'] = $media ? $resolver->altFor($media) : '';
return $data;
}
/**
* Which files this block depends on, so the page cache is dropped when
* one of them is replaced.
*
* @param array<string, mixed> $data
* @return array<int, int>
*/
public function mediaIdsFrom(array $data): array
{
return array_values(array_filter([(int) ($data['media_id'] ?? 0)]));
}
/** @param array<string, mixed> $data */
public function searchableText(array $data): string
{
return trim(($data['heading'] ?? '').' '.($data['description'] ?? ''));
}
}
What each method is for
| Method | What reads it |
|---|---|
| schema() | Block::form(), which places these fields on the block's Content tab and adds the Layout and Configuration tabs from commonFields(). The page builder in the panel, a container block's child builder and the front-end slide-over editor all render that same form. No other part of the CMS reads schema(). |
| rules() | Validation when the page is saved. The MCP server also builds the field list it returns from cms_describe_block out of these rules, so an AI client learns the block's field names, types and allowed values from them. |
| defaults() | Block::cast(), which merges stored data over the defaults. A block saved before a field was added therefore still has that key when it renders, and a field can be added without a data migration. |
| withView() | The renderer, on every render. Resolve ids into models here rather than in the template. |
| mediaIdsFrom() | VersionManager, when a version is saved. The ids are stored with the version and read by three consumers: the page cache index, the media usage report, and the guard that refuses to delete a file a live page uses. |
| searchableText() | VersionManager, which writes the search text once when the version is saved. If this method is omitted, the block's words are absent from the search index. |
4. Write the view
- In
resources/views/cms/blocks/feature.blade.php, replace the generated markup with this view:
{{-- $cmsAttrs carries six shared fields every block has: the width
preset, the alignment, custom classes, the anchor id, aria-label and
role. Merging it onto the root element applies them. --}}
<section {{ $cmsAttrs->class([
'cms-block',
'cms-block--feature',
'cms-block--feature-'.($image_position ?? 'left'),
]) }}>
@if ($image ?? null)
<div class="cms-feature__media">
{{-- Chooses the conversion, writes a srcset, and renders
nothing if the file was deleted. --}}
<x-cms::image
:media="$image"
:alt="$alt ?? ''"
class="cms-feature__image"
sizes="(min-width: 48rem) 50vw, 100vw"
/>
</div>
@endif
<div class="cms-feature__body">
<h2 class="cms-block__heading">{{ $heading }}</h2>
@if ($description ?? null)
<p class="cms-feature__description">{{ $description }}</p>
@endif
@if (($primary_label ?? null) || ($secondary_label ?? null))
<div class="cms-buttons">
@if (($primary_label ?? null) && ($primary_url ?? null))
<a class="cms-button cms-button--primary" href="{{ $primary_url }}">
{{ $primary_label }}
</a>
@endif
@if (($secondary_label ?? null) && ($secondary_url ?? null))
<a class="cms-button cms-button--secondary" href="{{ $secondary_url }}">
{{ $secondary_label }}
</a>
@endif
</div>
@endif
</div>
</section>
The view is plain Blade with one package-specific variable, $cmsAttrs. It is a ComponentAttributeBag built by Mcg\FilamentCms\View\Components\Block from the block's shared fields. Merging it onto the outermost element is what gives the block the same width, alignment, anchor and ARIA behavior as the package's built-in blocks.
Use the package's own classes where they exist. cms-button--primary, cms-button--secondary and cms-button--subtle are styled by the active skin, so a block that uses them changes appearance with the rest of the site.
5. Style the block
- In
resources/css/app.css, add the block's rules inside@layer components:
@layer components {
.cms-block--feature {
@apply grid items-center gap-8;
}
@media (min-width: 48rem) {
.cms-block--feature {
grid-template-columns: 1fr 1fr;
}
/* The source order stays image, then text, so a screen reader and a
narrow screen read the block in the same order on either side. */
.cms-block--feature-right .cms-feature__media {
order: 2;
}
}
.cms-feature__image {
@apply block h-auto w-full rounded-cms object-cover;
}
.cms-feature__description {
@apply mt-3;
color: var(--color-cms-muted);
}
.cms-block--feature .cms-buttons {
@apply mt-6 flex flex-wrap gap-3;
}
}
- In the same terminal, rebuild the assets:
npm run build
The rules use semantic class names with Tailwind utilities applied through @apply. Rules inside @layer components lose to Tailwind utilities, which sit in a later layer, so a utility class written directly in a Blade view still overrides them.
Colors, radii and spacing refer to the design tokens (--color-cms-muted, --color-cms-accent, --radius-cms) rather than literal values. A skin reassigns those tokens, so a block written against them changes with the skin. A block written with hex codes keeps its colors when the skin or the dark theme changes everything around it.
6. Place the block on a page
- In the admin panel, open Pages under Content Manager and open any page for editing.
- Click Add block and choose Feature. The picker lists blocks alphabetically by label. The
category()value,Media, groups the block on the Blocks & layouts screen and in the MCPcms_list_blockslisting; it does not group the picker. - Choose an image, type a heading, and fill in the two buttons.
- Click Save & publish at the top of the edit screen. To save and publish as two separate steps, use Save and then Publish from the More actions menu.
The published block shows the image on the left, with the heading, paragraph and buttons beside it. Setting Image position to Right moves the image to the other side. The markup and the source order do not change; only the CSS order rule moves the image, so a screen reader and a narrow screen still reach the image before the text.