Skip to content

Blocks

Namespace Hilms\Blocks. Lesson and page content is not a rich-text column: it is an ordered list of typed blocks, each one a class, a row and a Blade view. The module owns the kernel; individual blocks may live in any module.

This chapter is what a block is and how to write one. The block editor is the panel around them.

A class extending Hilms\Blocks\Block in a module’s src/Blocks directory. Two methods are abstract — type() and settings(), plus sample() — and everything else has an answer already:

Answers What it says
type(), label(), icon(), sort() Its key, its name (<module>::blocks.<type>.label, falling back to the headline of the type), its icon and where it sits in the picker
settings(): array The Filament components of its own settings
looks(): array, fields() The parts of its look an editor may choose instead of the style (Looks\LookControl: surface, padding, corners, width, alignment), none by default; fields() is settings() followed by the look, and is what every builder and the side panel draw (chapter 26)
category(): BlockCategory, description() The picker’s heading it sits under (Text, Media, Layout, Courses, Interactive) and the one line under its name (<module>::blocks.<type>.description)
innerBlocks(): ?InnerBlocks Null for a leaf; a container describes the one area of children it holds. isContainer() is that question asked
parents(): ?array Null anywhere; a list makes it a child-only block, offered inside those containers and never at the root
contexts() Where it may be used; every context by default
nestable() Whether it may sit inside a container
drawsPageHeading() Whether it brings the page’s own <h1>
once() Whether a page or a lesson holds it at most once
itemLabel(array $data) The words on its item header, usually built with the protected summary(): the first of the given keys holding text, stripped of markup and trimmed to the line it sits on
canvasLayout(), canvasHints() What a container tells the editor’s canvas about the layout its children stand in, and what a child tells it about the room it takes (the editor)
settingKeys() The names of its own settings fields, and look whenever the panel draws a look for it, which is what a refused save is matched against
enumOf() Reads a backed enum out of the block’s data — the case itself while the form is open, its value once stored — and null for anything else; it was option() until a theme’s options took that word
sample(), livewireComponent(), view() Data for tests, demos and the design catalogue; the Livewire component of an interactive block; the Blade view, always <module>::blocks.<type>
class PollBlock extends Block
{
public static function type(): string { return 'poll'; }
public static function icon(): string|BackedEnum { return Heroicon::OutlinedChartBar; }
public static function category(): BlockCategory { return BlockCategory::Interactive; }
public static function sort(): int { return 75; }
public static function contexts(): array { return [BlockContext::Lesson]; }
public function settings(): array { /* Filament components */ }
public function sample(): array { /* data that must render on its own */ }
public function itemLabel(array $data): ?string { return self::summary($data, 'question'); }
public function livewireComponent(): ?string { return 'blocks::blocks.poll'; }
}

BlockRegistry discovers every module’s src/Blocks, keys blocks by type and rejects duplicates. for(BlockContext) narrows that to what one editing context offers at the root — every block of the context that names no parents — only([...]) to a named list, and childrenOf($container, $context, $depth) to what a container offers inside itself.

Hilms\Blocks\Enums\BlockContext has Lesson and Page. A block declares the contexts it serves, and the editor is built with BlocksBuilder::make(BlockContext::X), so a picker never offers a block the model cannot use.

Block Contexts Why
question, quiz Lesson Both are asked of a student working through a course
page header, hero, latest courses, course listing, my courses Page A page opens with one, or is one; a lesson is already inside a course
everything else both

A model says which context it belongs to by answering blockContext().

Hilms\Blocks\InnerBlocks is an immutable description of the one area a container holds. Every wither answers a copy, so a block may hand the same description out twice without the second caller changing it:

public function innerBlocks(): ?InnerBlocks
{
return InnerBlocks::make()
->allow([GridCellBlock::type()])
->template([
['type' => GridCellBlock::type(), 'data' => []],
['type' => GridCellBlock::type(), 'data' => []],
])
->orientation(Orientation::Horizontal)
->min(1)
->max(12)
->addLabel(__('blocks::fields.grid.add_cell'));
}

The children live under InnerBlocks::KEY (blocks) in the parent’s own data, as {type, data} pairs:

grid.data.blocks[] = {type: 'grid-cell', data: {span, surface, padding, blocks: [...]}}

Nested items are stored as lists, because the editor’s item keys mean nothing once they are written down, and every nested item carries a uuid of its own, which is the slot its library files are recorded under.

A container class holds no Filament plumbing for its children: it describes the area, and the kernel builds the inner builder (BlocksBuilder::inner()).

BlockRegistry::MAX_NESTING is 2, and only a container that may sit anywhere counts a level (depthInside() steps only past a container with no parents()). A grid holds a grid and no deeper: Filament builds a nested schema the moment it builds the one above it, and the cap is what ends the recursion.

A model owns blocks by implementing Hilms\Blocks\Contracts\Blockable and using the HasBlocks trait:

interface Blockable
{
public function blocks(): MorphMany;
public function assetUses(): MorphMany;
public function blockContext(): BlockContext;
}

HasBlocks gives blocks() ordered by position and assetUses(), the media_asset_uses rows of the record, and forgets those rows when the record is deleted. A blockable holds no file of its own: every file a block shows is an asset of the media library, and picture sizes are the asset’s own conversions (Media).

Two models implement it, and both implement Hilms\Library\Contracts\UsesAssets as well: Lesson (context Lesson, takes course material) and Page (context Page, public files only). A third would add those methods and the morph-map alias.

One content_blocks row per root block: uuid, the blockable morph, type, data (JSON), position. Nested blocks live inside the parent’s data, because they have no identity of their own outside it.

The uuid is a plain char(36), not MariaDB’s native uuid type, because it is compared as a string: it is the slot a block’s library files are recorded under in media_asset_uses, and must come back byte for byte.

SyncBlocks::handle(Blockable $blockable, array $items) is the one writer:

  • it updates the row whose uuid matches, creates the ones that are new, and deletes the ones that disappeared;
  • it writes nested lists down as lists and every asset as an integer, whatever the field held it as;
  • it collects every asset in the tree under the uuid beside it — the block’s own, or a nested item’s — and hands the lot to RecordUses in one call, so a file a block stopped showing stops opening through this record the moment it is saved. No file is deleted: files are deleted in the library, and only when nothing uses them.

Hilms\Blocks\Support\BlockTree is the walk both the rules and the outline read: flatten() gives every block in the tree in the order a reader meets them, walk() the same plus how deep each one sits and the path the editor addresses it by (<item>.data.blocks.<item>), which is also how a refused save names a field. A nested block is recognised the way SyncBlocks recognises one — a type with a data array beside it — wherever in the parent’s data it sits, so the walk needs to know nothing about the shape of the container holding it.

They hang on the root field, which is the only place the whole tree is visible, and each reads Hilms\Blocks\Support\HeadingOutline:

Rule Refuses
HeadingHierarchy A heading that goes down more than one level at a time, counting a block that draws the page heading as the h1 and the record’s own title as the h1 when none does
SinglePageHeading A second block drawing the page heading. It is about a family of types, whichever of them draws it
OncePerBlockable A second copy of a block that answers once()

HeadingOutline walks the tree with BlockTree::flatten(), so a block inside a grid cell counts exactly as much as one at the top — and so the editor’s outline can never warn about one thing while Save refuses another. A block that only ever lives inside another (a grid’s cell) is structure and not content: it is left out of the outline, and what it holds keeps the level of the container above it.

A screen reader’s list of headings is how most of its users find their way around a long page, and a jump from h2 to h4 tells them a level is missing without saying what (Accessibility).

courses-list and my-courses answer once() because both keep state in the address: the first its search, its filters and its page number, the second its page number under a name of its own. Two of either would read the same state and move together. The builder block also carries Filament’s maxItems(1), which stops the picker offering it at that level; the rule is what catches a second copy deeper in a container.

A block names a library file by its id under the key asset, chosen with Hilms\Library\Filament\AssetPicker:

AssetPicker::make('asset')
->label(__('blocks::fields.image'))
->kinds([AssetKind::Image])
->required(),

The picker offers the library as the signed-in person may see it, narrowed to the kinds the place shows and to what the record may show: a lesson takes course material and public files, a page only public files. “Upload new” beside it brings a file into the library and chooses it in one go, and the server asks the same three questions of whatever id the request carries. A gallery item names its own asset beside a hidden uuid of its own, which is the slot its picture is recorded under.

Nothing else about a file is written into block data. Where the bytes live, who may open them and at what address are the library’s business, and Media is that whole story.

Every block has one Blade view in the theme, <module>::blocks.<type>, receiving five variables:

Variable Is
$data The block’s data, with its uuid
$assets A Hilms\Blocks\Support\BlockAssets: the library files this record shows, and the place showing them — find($id), file(), url($asset, ?conversion), mediaUrl($media), captions($asset), alt($asset, $override)
$preview True inside the panel
$parent What the block around this one tells its children; empty at the top
$nested A closure that renders nested block items, optionally with a $parent of its own

$parent is how a cell knows what kind of grid it is in without reading the grid: grid.blade.php passes ['mode' => …, 'stack_below' => …] down, and grid-cell.blade.php reads it. Drawn without a grid — stored data nobody meant — a cell is simply a box around what it holds.

BlockRenderer::render($type, $data, ?BlockAssets, $preview, $parent) renders one type with data; <x-blocks::content :blockable> renders a model’s blocks, sending interactive ones through their Livewire component and skipping an unknown type with a log warning. It expects blocks and assetUses.asset.media eager-loaded, so a page costs the same queries however many files it shows, and it draws only the files the record has recorded as used, each address minted for that record. Panel previews use the same views through HilmsBlock::renderPreview(), whose BlockAssets::preview() holds whatever the item names at that moment, so a pick shows before anything is saved.

Because every block view is a contract view, adding a block adds a line to the view contract automatically — the registry is what blocks declares in its ContractViews.

A block that must adapt answers to the room it has and not to the window, because the same block is met inside a grid cell, inside a narrow cell inside a grid, and across a whole page. The default theme’s grid is its own @container and so is any cell with a width of its own, and the breakpoints inside them are @sm:, @lg:, @2xl: rather than sm:.

A cell sized by its content is deliberately not a container: one has no width at all, so there is nothing to ask.

Tailwind reads class names out of a file, so every one of them is written out and none is built by joining strings — which is why grid.blade.php holds a literal table of track classes per stacking threshold rather than a "grid-cols-{$n}".

Type Fields Notes
heading text, level h2–h4
rich-text content The one rich editor
callout type (info, tip, warning, danger), title, body An <aside> named after its title or its kind, drawn with an icon and a word as well as a colour
quote text, source
image a picture of the library, a live decorative toggle, words for this place only, caption, width The words are required only when the file says nothing in the record’s language nor the default one
gallery items (1 to 24, reorderable), each a picture of the library with its own decorative toggle, words and caption, plus a caption for the gallery Each item is a slot of its own uuid
video provider (YouTube, Vimeo, Bunny Stream, upload), URL or library and video id or a video of the library, transcript, caption URLs validated by VideoUrl; an upload’s caption tracks are its library file’s own
audio a recording of the library, title, transcript
file a document or a picture of the library, label The label falls back to the file’s title; the link carries the name and the size
code language, code No highlighting; the <pre> is a focusable named region
grid mode (grid or flex), 1–6 columns, gap, alignment, justification, where it stacks; 1 to 12 cells The one container; see below
grid-cell span, surface, padding, and blocks of its own Child-only: offered inside a grid and nowhere else
accordion items of title and rich content
embed URL, title (required — the frame’s only name), aspect ratio, transcript Host must be on config('blocks.embed_hosts'); sandboxed iframe
divider —
question prompt, 2–6 answers with at least one correct, explanation Interactive; lesson only

Every grid and cell option is an allowlisted enum the view maps to literal class strings built on tokens, so stored data names a layout and never a style, and a value nobody recognises reads as the default. In grid mode a span is a number of columns; in flex mode it is the cell’s share of the row.

Five more live outside this module: the graded quiz and my courses in learning, latest courses and the course listing in catalog, and the page header and hero in pages (see Learning, Catalog and Pages).

The question block is the simplest interactive one and the model for any other: Hilms\Blocks\Livewire\Blocks\Question sends only the prompts and option texts to the browser, grades on the server, shows feedback and the explanation after answering, offers “try again”, and persists nothing.

Media is offered another way as well as played: an uploaded video renders one <track kind="captions"> per caption track of its library file, and video, audio and embed take a transcript rendered into a <details>. An image says what it shows or says it shows nothing. Accessibility is the whole of that story, and the uploaded player’s watermark and full-screen button are in Security and privacy.

A box-like block — the grid cell, the callout, the question, the quote, the accordion, the hero, the image, the code listing, the embed and the video — may be given a look of its own over the style’s: its surface, padding, corners, width or alignment, and the theme’s variant for its type, each one of the style’s own options and never a free value. The choices live under one look key of the block’s data, behind a Follow the style switch that is on until somebody turns it off; Looks\Look writes them as --look-* custom properties on the block’s root, and the view reads each behind a fallback that is what the block drew before, so a block that follows the style draws exactly as it did. Copying, syncing, the validator and the MCP tools keep the key as it is. Components, looks, options and generators explains the whole mechanism, which block takes what, and how to give a new block a look and a default look of its own.

A pattern is a handful of blocks an editor inserts in one go. A class in a module’s src/Patterns extending Hilms\Blocks\Pattern, answering key(), label() and description() (<module>::patterns.<key>.*), icon(), sort(), contexts(), blocks() and awaits().

Its items carry no uuid — one is minted for every block at every level as it is inserted, so two copies of one pattern are never one place — and its words go through __(), because a pattern is written in the language the editor is working in. Nothing on a page remembers it came from a pattern.

PatternRegistry discovers them the way blocks are discovered and rejects a duplicate key.

Hilms\Blocks\Support\PatternBlocks is the door a pattern comes through:

  • validate() refuses a block nothing registers, a block the editor would not offer where the pattern puts it (another context, a child-only block outside its parent, a container past the nesting cap), and anything the document outline would object to. The suite walks every registered pattern with it; the insert action asks it again before anything reaches the canvas.
  • fits() is the narrower question the picker asks of the page in front of it: a once() block the tree already holds, and a second page heading.

awaits() is a pattern saying which block types it knows arrive incomplete. Some of what a pattern lays out cannot be filled in from code — a picture is a file somebody has to choose. The suite inserts every pattern in every context it declares and presses Save: one that awaits nothing must save exactly as it arrives, and one that awaits something is refused only on blocks of the types it named. That turns “no pattern may hold an image” into “a pattern says what it leaves to the editor”.

The patterns this installation ships: Hero and three features, Text beside an image, Course catalogue page and Call to action (Hilms\Pages\Patterns, page only), Two columns of text and Lesson opening (Hilms\Blocks\Patterns, the first in both contexts, the second in a lesson). “Text beside an image” is the one that awaits anything: a two-column grid with the words on one side and an image block on the other, which lands wearing the mark of the file it is still missing and draws nothing at all on the page until it has one.

The catalogue pattern is also what StarterPages writes the starter catalogue page from, so the page an editor would insert and the page an installation is handed are one definition (Pages).

Hilms\Blocks\Support\BlockCopy is one block copied: its data with every uuid in it minted again, naming the same library files. A uuid is the place a block shows its files in, so a copy that kept them would be the same place twice; the files themselves are shown by reference, and no byte is copied.

  • BlockCopy::items() is the same for a list, giving a uuid to every block that has none — which is how a pattern is inserted.
  • Hilms\Blocks\Actions\CopyBlocks uses it to copy everything one model holds onto another and writes the copy through SyncBlocks, which records the files as used by the new model — that is how a translated lesson or page gets content of its own (Languages).
  • The editor’s duplicate action uses it for one block, whose uses the next save records (the editor).

Three readers, and no fourth:

  • RichText::html() renders what a Filament rich editor stored, whether an HTML string or an editor document, through RichContentRenderer. Everything written by a human and displayed later goes through it — including the course description, which is why nothing in that column can reach the page unsanitised. RichText::excerpt() is the plain-text form.
  • Markdown owns the one CommonMark environment machine-written Markdown is read in: html_input: strip, allow_unsafe_links: false, and every image replaced by its alt text before rendering, because an installation hosts its own media and the panel is where a picture is added. Markdown::html() renders such Markdown into the HTML of a rich-text column.
  • MarkdownToBlocks::convert() is the only way machine-written prose becomes blocks, which is why the palette a model can write is four types wide (types(): heading, rich-text, quote, code). Headings clamp to h2–h4, a blockquote becomes a quote, fenced and indented code a code block, and every run of paragraphs and lists between them one rich-text block; a thematic break only ends such a run.

Both the AI assistants and the MCP authoring tools write through these, so what a machine produces passes exactly the checks a person’s typing does.

Hilms\Blocks\Filament\RichEditorField::make() is the one rich editor the system offers: emphasis, links, h3 and h4, quote, code, lists, table, undo and redo, and no file attachments. Text colour and alignment are not offered — a colour no token defines is a colour no contrast test can see — and a table has no caption or header scope, because the editor cannot express either.

bin/artisan hilms:make-block Poll --module=learning --interactive
bin/artisan hilms:make-block Panel --module=blocks --container
bin/artisan hilms:make-block PanelTab --module=blocks --parent=panel

The generator writes the class into app-modules/<module>/src/Blocks, a Pest test beside it and the view into themes/<theme>/views/<module>/blocks/<type>.blade.php. --interactive adds a Livewire component in src/Livewire/Blocks with its own view; --container scaffolds the innerBlocks() a block that holds blocks answers and a view that renders them; --parent=<type> scaffolds the parents() of a block made for one container alone. --looks=surface,padding gives a box-like block a look and the view its var() fallbacks; --variants=soft,bold declares the type’s variants in the theme’s manifest and gives every view the switch (chapter 26, which also has the recipe for a block’s own default look). Asked for the name at the prompt, it offers both. It refuses an unknown module or theme and never overwrites a file it scaffolds (with --variants it does write the theme’s manifest, refusing a type that already declares variants). Then:

  1. Fill in settings(), with an AssetPicker under the key asset for any file the block shows (Media has the steps) and RichText::html() for anything rich the view prints. No field is called look: that key is the look’s.
  2. Make sample() render on its own: it is what the design catalogue and the tests draw. A block that shows a file gets a sample file from ContractViews::withSampleAssets() in blocks; add the type there (Media), or the catalogue never draws the file and nothing checks it. Make the view draw when a setting is missing, too: an import, an assistant or an older version of the block can store less than the editor would, and a view that reads $data['type'] after checking $data['type'] ?? 'info' throws and takes the whole page down — the callout and the heading both did until the performance load turned it up. Check with ?? null and fall back to the default.
  3. Narrow contexts() if the block does not belong everywhere; answer once() or drawsPageHeading() if either is true of it, and nestable() false if it needs a row of its own and may not sit inside a container.
  4. Choose a category() and write a description(): both are what the picker reads.
  5. Add the label and the description to <module>::blocks.<type> in both languages. A block of a module keeps its field labels beside them, under fields (pages::blocks.hero.fields.heading); the built-in blocks of blocks share blocks::fields.
  6. Run the suite: ViewContractTest will already have picked the new view up from the registry.
  7. A box-like block with a look: give it a default look of its own — a token group, its contract entries, labels, contrast pairs and fallbacks, and its line in ThemeStyleGuardTest — by the recipe in chapter 26, which is where the generator’s closing line points.
bin/artisan hilms:make-pattern CallToAction --module=pages

It scaffolds a heading and a paragraph — valid in every context on the day it is written — and names the translation keys to fill in, the contexts to narrow and the block types to declare in awaits(). Write the blocks the way they are stored ({type, data}, nested items under the container’s own key, no uuids), put every phrase in <module>::patterns.<key>.* in both languages, and run the suite: it will insert the pattern and press Save for you.

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.