Creating a block, step by step
This guide builds testimonial: a quote, the name of the person who said it, who they are, and their portrait from the media library, for the pages of a site. An editor can lay a surface and padding over it. Every step below was run for this guide in a sub-tree of its own, and every file and message it shows is what that run produced; each step links to the chapter that explains why it is so.
Work in a sub-tree of your own, as for any change (Quality), and run everything below inside it:
bin/tree new fix/testimonial-blockcd ../hi-lms-trees/testimonial-blockA block lives in the module whose pages or lessons it serves, and any module may hold one (Blocks). A testimonial sells a course from a page, so it goes into pages.
1. Scaffold it
Section titled “1. Scaffold it”bin/artisan hilms:make-block Testimonial --module=pages --looks=surface,paddingINFO Created app-modules/pages/src/Blocks/TestimonialBlock.php.INFO Created themes/hilms/views/pages/blocks/testimonial.blade.php.INFO Created app-modules/pages/tests/Feature/Blocks/TestimonialBlockTest.php.INFO Add a label and a one-line description under [pages::blocks.testimonial] in resources/lang/{pl,en}/blocks.php, and give the block its category.INFO What next: the testimonial block draws nothing of its own behind its look yet.INFO Once it has a default look, give it a token group of its own so a style can change every testimonial block; the dev-book chapter on blocks has the recipe.Three files: the class, its view in the theme (every Blade file a visitor sees belongs to a theme, Theming) and a test. --looks=surface,padding is what lets an editor choose a surface and padding for this one block; leave it out for a block that should always follow the style. The generator also takes --container, --parent=<type>, --interactive and --variants=…, refuses a module or theme it does not know, and never overwrites a file it scaffolds (Adding a block).
2. Let the suite say what is missing
Section titled “2. Let the suite say what is missing”Run the suite on the bare scaffold before writing anything:
bin/pestThe generated test passes already — it draws the block’s sample — and four guards fail, each naming a decision the generator cannot make for you:
| Test that fails | What it says | What it asks of you |
|---|---|---|
BlockPickerTest: “gives every block a category and a line of help in both languages” |
[pages::blocks.testimonial.description] is missing in English |
The label and the description (step 4) |
BlockPickerTest: “says what each block is for and searches on the same words” |
Failed asserting that 19 is identical to 20 |
The same: the picker draws a description for every block it offers |
ViewContractTest: “gives every declared view at least one variable, or none on purpose” |
actual size 23 matches expected size 22 |
Raise the count of block views to 23: a new view is something you meant to add |
BlockLookTest: “names every block that takes a look” |
+ 'testimonial' |
Add 'testimonial' to LOOKED_BLOCKS: a block that takes a look is a choice, not an accident |
The last two are deliberate tripwires. Change them in the same commit as the block; never loosen them.
3. Its settings
Section titled “3. Its settings”The fields an editor fills in are the block’s settings(), drawn on the side panel when the block is selected (The settings panel). Their names are the keys the block’s data is stored under. The scaffold imports TextInput alone; add use lines for Filament\Forms\Components\Textarea, Hilms\Library\Filament\AssetPicker and Hilms\Library\Enums\AssetKind.
public function settings(): array{ return [ Textarea::make('quote') ->label(__('pages::blocks.testimonial.fields.quote')) ->required() ->rows(4) ->maxLength(600), TextInput::make('name') ->label(__('pages::blocks.testimonial.fields.name')) ->required() ->maxLength(120), TextInput::make('role') ->label(__('pages::blocks.testimonial.fields.role')) ->maxLength(160), AssetPicker::make('asset') ->label(__('pages::blocks.testimonial.fields.portrait')) ->kinds([AssetKind::Image]), ];}- A file is always a library asset under the key
asset, chosen withAssetPickerand never with an upload field of the block’s own. The library records where every file is shown, and on a page the picker offers only public files (Adding a block that shows a file). - No field is called
look: that key belongs to the look an editor chooses. - Every field is validated when the page is saved; a refused save marks the block on the canvas (A refused save is never silent).
4. What it says about itself
Section titled “4. What it says about itself”public static function icon(): string|BackedEnum{ return Heroicon::OutlinedChatBubbleLeftRight;}
public static function category(): BlockCategory{ return BlockCategory::Text;}
/** * What a former student says sells a course; a lesson has no use for it. * * @return list<BlockContext> */public static function contexts(): array{ return [BlockContext::Page];}
public function itemLabel(array $data): ?string{ return self::summary($data, 'name', 'quote');}with use Hilms\Blocks\Enums\BlockContext; at the top.
category()is the group the picker lists the block under.contexts()keeps it out of lessons: the lesson editor never offers it (Contexts).itemLabel()is the line on the block’s header in the editor;summary()builds it from the first of the fields that has words, as text and never markup.- Three more answers keep their defaults here.
nestable()is true unless a block needs a row of its own, so a testimonial may sit in a grid cell (Containers);once()anddrawsPageHeading()are false unless a block may stand only once on a page or brings the page’s<h1>(The three rules).
Its words go into the module’s language files, in both languages. A block of a module keeps its field labels beside its label and description, as the pages module’s other blocks do; only the built-in blocks share blocks::fields. app-modules/pages/resources/lang/en/blocks.php:
'testimonial' => [ 'label' => 'Testimonial', 'description' => 'What a student says about a course, with their name and a picture.', 'fields' => [ 'quote' => 'Quote', 'name' => 'Name', 'role' => 'Who they are', 'portrait' => 'Portrait', ],],and pl/blocks.php the same keys in Polish — 'Opinia', 'Opinia o kursie: cytat, imię i nazwisko oraz zdjęcie.' — written so that it assumes nobody’s gender. TranslationParityTest fails on a key that one language has and the other does not (Languages).
5. The view
Section titled “5. The view”themes/hilms/views/pages/blocks/testimonial.blade.php:
{{-- The portrait never says more than the name below it, so it is drawn as decoration, with no alternative text of its own. In a narrow place it sits above the quote; given the room, beside it — the room the block has, not the window. --}}@php($portrait = $assets->url($assets->find($data['asset'] ?? null), 'thumb'))<figure {{ $look->attributes() }} class="my-block @container bg-(--look-surface,transparent) shadow-(--look-shadow,0_0_#0000) ring-1 ring-(--look-border,transparent) p-(--look-padding,0)"> <div class="flex flex-col gap-4 @md:flex-row @md:items-start"> @if ($portrait) <img src="{{ $portrait }}" alt="" class="size-16 shrink-0 rounded-full object-cover"> @endif <blockquote class="text-lg wrap-break-word text-ink"> <p>{{ $data['quote'] ?? '' }}</p> </blockquote> </div> <figcaption @class(['mt-3 text-sm text-ink-muted', '@md:ps-20' => $portrait])> <span class="font-semibold text-ink">{{ $data['name'] ?? '' }}</span>@if (filled($data['role'] ?? null)), {{ $data['role'] }}@endif </figcaption></figure>What every line is held to:
- The look on the root, and only the look it declared.
$look->attributes()writes every custom property of the controls the block declared — what the editor chose,initialfor the rest — and the view reads exactly those: surface sets--look-surface,--look-shadowand--look-border, padding sets--look-padding, each read throughvar()with what the block draws without a look behind it (Drawing it).BlockLookTestholds the view to it: it draws the sample with a choice for every control and fails when the look is not on the root, when the view reads a property the block does not take, or when it leaves one it declared unread. - Tokens, never colours.
text-inkandtext-ink-mutedare the theme’s semantic tokens (Design tokens); a raw palette utility such astext-slate-700, or adark:variant, failsThemeStyleGuardTest(Theming). - The room it has, not the window. A block may be dropped into a narrow grid cell, so it is its own
@containerand switches layout with@md:, never withlg:(Container queries). - A figure, properly. The
figcaptionis the figure’s last child, as HTML requires, so the name labels the quote; the portrait and the quote share a row inside it.wrap-break-wordkeeps one very long word from pushing a narrow column sideways. - Files through
$assets.find()resolves the asset the block names andurl()mints its address, thethumbsize here; a view never reaches for a file by itself, because the record says which files it shows and course material opens only through a lesson (In a block). - Whatever was not saved. An import, an assistant or an older version of the block can store less than the editor would, so every key is read with
?? ''orfilled(): a view that reads a missing key takes the whole page down. - This block’s choice about its picture. The rule for a picture that may mean something is a
decorativeswitch andAltText(step 3 of Adding a block that shows a file). A portrait beside the name it belongs to never says more than that name, so this block draws it decorative, withalt="", and asks the editor nothing (Media and blocks). - One Blade file holds
@php(...)or@php … @endphp, never both.
6. Its sample and its tests
Section titled “6. Its sample and its tests”sample() is what the design catalogue and the tests draw, so it renders on its own:
public function sample(): array{ return [ 'quote' => 'I finished the course on a phone, on the bus, a lesson a day. It never once felt like homework.', 'name' => 'Ala Kowalska', 'role' => 'Student of the evening course', ];}A sample holds no file, so the catalogue hands a block that shows one an unsaved sample asset — but only a block it knows about. Add the type to withSampleAssets() in app-modules/blocks/src/Theme/ContractViews.php, beside the image and the hero, or the portrait is never drawn by the catalogue and never checked by axe (step 5 of Adding a block that shows a file):
'image', 'hero', 'testimonial' => [...$data, 'asset' => self::DIAGRAM],The generated test draws the sample. Add what the block promises beyond that, in app-modules/pages/tests/Feature/Blocks/TestimonialBlockTest.php (with use Hilms\Blocks\BlockRegistry; and use Hilms\Blocks\Enums\BlockContext;):
it('draws without a portrait or a role, and without the words an editor never saved', function (): void { $html = (string) app(BlockRenderer::class)->render('testimonial', ['name' => 'Ala Kowalska', 'uuid' => 'bare']);
expect($html)->toContain('Ala Kowalska') ->not->toContain('<img') ->not->toContain(', ');});
it('is offered on pages and never in a lesson', function (): void { $registry = app(BlockRegistry::class);
expect($registry->for(BlockContext::Page))->toHaveKey('testimonial') ->and($registry->for(BlockContext::Lesson))->not->toHaveKey('testimonial');});Then the two tripwires from step 2: toHaveCount(23) in app-modules/theme/tests/Feature/ViewContractTest.php, and 'testimonial' at the end of LOOKED_BLOCKS in app-modules/blocks/tests/Feature/BlockLookTest.php.
7. Check it
Section titled “7. Check it”bin/pest --no-parallel app-modules/pages/tests/Feature/Blocks/TestimonialBlockTest.php \ app-modules/blocks/tests/Feature/BlockPickerTest.php app-modules/blocks/tests/Feature/BlockLookTest.php \ app-modules/theme/tests/Feature/ViewContractTest.phpnpm run buildbin/artisan hilms:theme:a11ybin/artisan hilms:design:export --splitnpm run a11ynpm run build comes first because the view uses utilities nothing else in the theme does (size-16, @md:items-start, @md:ps-20): until the theme’s stylesheet is built again, the export, axe and the site all draw the block without them. In the run for this guide the four test files passed together, 75 tests; hilms:theme:a11y checked its eight rules with no finding; the export wrote 136 documents, among them block/pages-blocks-testimonial.light.html and .dark.html, each drawing the sample portrait; and axe found no violation in the 134 it scans (it leaves the two mails out). The picker lists Testimonial under Text with its description, which BlockPickerTest reads from the page editor itself. The accessibility checks are explained in Accessibility.
Last, every gate on both database engines, which is what a push needs. bin/gates checks and fixes nothing itself and wants a committed tree, so tidy and commit first:
bin/pintbin/rectorgit add -A && git commit -m "feat(pages): a testimonial block"bin/gatesIt runs Pint and Rector as checks, PHPStan, and the whole suite on MySQL and on MariaDB, and records the pass so the pre-push hook lets the branch out (Before a push). In the run for this guide it passed on both engines, 3,106 tests each. Say in SPECS.md that pages now offer a testimonial — it lists the blocks that serve pages only — and merge the sub-tree into main as usual.
Where to go next
Section titled “Where to go next”- A default look of its own. The generator’s last line: once the block draws something behind its look, give it a token group so a style can change every testimonial at once (A block’s own default look).
- Variants, when the theme should offer several arrangements of the same block:
--variants=…(Options and variants). - A pattern that inserts a heading and three testimonials in one go:
bin/artisan hilms:make-pattern(Adding a pattern).
HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.