Skip to content

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-block
cd ../hi-lms-trees/testimonial-block

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

bin/artisan hilms:make-block Testimonial --module=pages --looks=surface,padding
INFO 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).

Run the suite on the bare scaffold before writing anything:

bin/pest

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

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 with AssetPicker and 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).
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() and drawsPageHeading() 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).

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, initial for the rest — and the view reads exactly those: surface sets --look-surface, --look-shadow and --look-border, padding sets --look-padding, each read through var() with what the block draws without a look behind it (Drawing it). BlockLookTest holds 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-ink and text-ink-muted are the theme’s semantic tokens (Design tokens); a raw palette utility such as text-slate-700, or a dark: variant, fails ThemeStyleGuardTest (Theming).
  • The room it has, not the window. A block may be dropped into a narrow grid cell, so it is its own @container and switches layout with @md:, never with lg: (Container queries).
  • A figure, properly. The figcaption is 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-word keeps one very long word from pushing a narrow column sideways.
  • Files through $assets. find() resolves the asset the block names and url() mints its address, the thumb size 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 ?? '' or filled(): 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 decorative switch and AltText (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, with alt="", and asks the editor nothing (Media and blocks).
  • One Blade file holds @php(...) or @php … @endphp, never both.

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.

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.php
npm run build
bin/artisan hilms:theme:a11y
bin/artisan hilms:design:export --split
npm run a11y

npm 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/pint
bin/rector
git add -A && git commit -m "feat(pages): a testimonial block"
bin/gates

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

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