Building a Livewire block
This tutorial builds the Feature block from Building a custom block a second time, as a single-file Livewire block, and then compares the two forms. The block is a feature panel with an image, formatted text and up to two buttons. The tutorial is written for a developer who has followed the class-based tutorial and needs a block that responds to input after the page has loaded.
A block renders in one of two forms:
- A class-based block is a PHP class in
app/Cms/Blocksand a Blade view. The view is included when the page renders, and the block does nothing after the page has loaded. - A Livewire block is one Livewire single-file component. It is mounted when the page renders, so it can respond to input after the page has loaded. The block's definition and its markup are in the same file.
1. Generate the file
- In a terminal at the site's root, run the generator with the
--livewireoption:
php artisan cms:make-block Feature --livewire
The command prints Block [feature] created., the path of the file, and a reminder not to add a cms-block class to the component.
The command writes one file, resources/views/livewire/blocks/feature.blade.php, and the block key is feature. It writes no class in app/Cms/Blocks and no view under resources/views/cms/blocks.
The file goes under resources/views/livewire because that directory is one of the two that Livewire searches by default, listed in livewire.component_locations, so the component name resolves without configuration. The package scans the blocks subdirectory, which is set by cms.blocks.livewire in config/cms.php, so the site's other Livewire components are not compiled at boot to check whether they are blocks. The subdirectory is part of the component name, so this component is blocks.feature.
2. Read the generated file
The generated file has two parts. The first part is a PHP class with a static block() method, which defines everything the editor needs. With the stub's comments removed, it reads:
new class extends Component implements DefinesBlock
{
public string $heading = '';
public static function block(): BlockDefinition
{
return BlockDefinition::make('feature')
->label('Feature')
->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);
}
};
The second part, after the closing ?>, is the markup the page renders.
block() is static because the panel builds the block's form without rendering the component, so there is no component instance to call. Every setting the definition leaves out falls back to the default in Mcg\FilamentCms\Contracts\Block. A block with no description and no content-type restriction therefore does not mention either.
The package discovers the file the same way it discovers classes in app/Cms/Blocks. It asks Livewire what each *.blade.php file under resources/views/livewire/blocks compiles to, and registers each component that implements DefinesBlock. Each one is wrapped in a SingleFileBlock, so the editor, the validator, the renderer and the search indexer all receive an ordinary Block object.
3. Write the definition
The schema has three parts: an image, the text, and a repeater of buttons. The repeater is limited to two items.
- In
resources/views/livewire/blocks/feature.blade.php, replace theblock()method with this definition:
public static function block(): BlockDefinition
{
return BlockDefinition::make('feature')
->label('Feature')
->category(Block::CATEGORY_INTERACTIVE)
->schema([
MediaOptions::picker('media_id')
->label('Image'),
RichEditor::make('body')
->label('Text'),
Repeater::make('buttons')
->schema([
TextInput::make('label')->required()->maxLength(40),
TextInput::make('url')->required()->maxLength(2048),
])
->maxItems(2)
->columns(2)
->columnSpanFull(),
])
->defaults(['media_id' => null, 'body' => null, 'buttons' => []])
->rules([
'media_id' => ['nullable', 'integer'],
'body' => ['nullable', 'string'],
'buttons' => ['array', 'max:2'],
'buttons.*.label' => ['required', 'string', 'max:40'],
'buttons.*.url' => ['required', 'string', 'max:2048'],
])
->searchable(['body'])
->mediaIdsFrom(static fn (array $data): array => array_values(array_filter(
[(int) ($data['media_id'] ?? 0)],
)));
}
searchable() names the fields whose text goes into the search index, and mediaIdsFrom() returns the media ids the block uses. They do the same work as searchableText() and mediaIdsFrom() on a class-based block. Without mediaIdsFrom(), replacing the image in the media library does not purge the cached pages that show it, the media usage report lists the file as unused, and the delete guard does not protect it.
4. Declare the fields on the component
This step has no equivalent in a class-based block, and getting it wrong is the most common reason a Livewire block does not receive its data.
The block's stored data arrives as mount parameters. Livewire sorts mount parameters into two groups: names the component declares as a public property or a mount() argument, and everything else. It passes everything else to the view as $attributes, to be merged onto the root element. An undeclared field therefore does not disappear. If the root element is written the usual Livewire way, <div {{ $attributes }}>, an undeclared field is written into the markup as an HTML attribute.
The dispatcher removes the seven shared fields (hidden, width, alignment, custom_classes, anchor_name, aria_label and aria_role) before mounting the component, so only the block's own fields need declaring.
- In
resources/views/livewire/blocks/feature.blade.php, replace the whole file with this component. Theblock()method is the one written in step 2:
<?php
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\TextInput;
use Livewire\Component;
use Mcg\FilamentCms\Contracts\Block;
use Mcg\FilamentCms\Contracts\DefinesBlock;
use Mcg\FilamentCms\Support\BlockDefinition;
use Mcg\FilamentCms\Support\MediaOptions;
use Mcg\FilamentCms\Support\MediaResolver;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
new class extends Component implements DefinesBlock
{
public ?int $media_id = null;
public ?string $body = null;
/** @var array<int, array<string, string>> */
public array $buttons = [];
public static function block(): BlockDefinition
{
/* The definition from step 2. */
}
public function image(): ?Media
{
return app(MediaResolver::class)->find($this->media_id);
}
};
?>
<div class="cms-feature">
@if ($image = $this->image())
<img class="cms-feature__image" src="{{ $image->getUrl() }}" alt="{{ $image->getCustomProperty('alt') ?? '' }}">
@endif
@if (filled($body))
<div class="cms-feature__body">{!! $body !!}</div>
@endif
@if ($buttons !== [])
<div class="cms-feature__actions">
@foreach ($buttons as $button)
<a class="cms-button @if (! $loop->first) cms-button--secondary @endif" href="{{ $button['url'] }}">
{{ $button['label'] }}
</a>
@endforeach
</div>
@endif
</div>
MediaResolver::find() returns null for a deleted media file rather than throwing, and the markup then omits the image. The package's image block follows the same rule.
5. Style the block
The wrapper already has the cms-block class, so the block's outer spacing and width are styled by the package. Only the inside of the block needs rules.
- In
resources/css/app.css, add the block's rules inside@layer components:
@layer components {
.cms-feature__image {
@apply w-full rounded-cms;
aspect-ratio: 16 / 9;
object-fit: cover;
}
.cms-feature__body {
@apply mt-4;
max-width: var(--cms-measure);
}
.cms-feature__actions {
@apply mt-6 flex flex-wrap gap-3;
}
}
- In the same terminal, rebuild the assets:
npm run build
6. Place the block on a page
Single-file blocks are discovered automatically, so the block needs no registration.
- In the admin panel, open Pages under Content Manager and open a page for editing.
- Click Add block and choose Feature.
- Choose an image, type the text, and add a button.
- Click Save & publish.
Choosing between the two forms
| Aspect | Class-based | Livewire |
|---|---|---|
| Files | A class and a view | One |
| Markup | A Blade view, included | A component, mounted |
| Shared fields | The view merges $cmsAttrs | The dispatcher's wrapper carries them |
| JavaScript on the page | None | Livewire's runtime |
| Responds to input after load | No | Yes |
| Works in a cached page | Yes | Yes, and the rendered output is cached with the page |
| Visible to static analysis | Yes | No; the definition is inside a Blade file |
| Use it when | The block displays content | The block holds state |
The row "Works in a cached page" decides most cases. The page cache stores a Livewire block's rendered output with the rest of the page, so a component whose output differs between visitors is cached and then served to other visitors. Keep the output identical for every anonymous visitor, or pass ->cacheable(false) in the definition. Authenticated requests bypass the cache, so the exposure is one anonymous visitor receiving another anonymous visitor's render.
When in doubt, build the block class-based. A Livewire block loads Livewire's runtime on every page it appears on, for every visitor, whether or not the block uses it.
Two further costs apply even to a block that needs state:
- A large schema is harder to read inside a Blade file than in a class. Sixty lines of
block()under a<?phptag are harder to navigate than the same lines in a class. - Nothing in the single-file form is visible to PHPStan, to a refactoring tool, or to an editor's "find usages".
A class-based block can still render a Livewire component: return true from isLivewire() and the component name from livewireComponent(). Use that form when a single-file block has grown large enough that these two costs outweigh keeping everything in one file.