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.
What a block answers
Section titled “What a block answers”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.
Contexts
Section titled “Contexts”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().
Containers
Section titled “Containers”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.
Blockable
Section titled “Blockable”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.
Storage
Section titled “Storage”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
assetas an integer, whatever the field held it as; - it collects every
assetin the tree under the uuid beside it — the block’s own, or a nested item’s — and hands the lot toRecordUsesin 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.
The three rules
Section titled “The three rules”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.
Files in a block
Section titled “Files in a block”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.
Rendering
Section titled “Rendering”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.
Container queries
Section titled “Container queries”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}".
The built-in blocks
Section titled “The built-in blocks”| 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.
Looks and variants
Section titled “Looks and variants”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.
Patterns
Section titled “Patterns”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: aonce()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).
Copying blocks
Section titled “Copying blocks”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\CopyBlocksuses it to copy everything one model holds onto another and writes the copy throughSyncBlocks, 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).
Reading untrusted text
Section titled “Reading untrusted text”Three readers, and no fourth:
RichText::html()renders what a Filament rich editor stored, whether an HTML string or an editor document, throughRichContentRenderer. 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.Markdownowns 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.
Adding a block
Section titled “Adding a block”bin/artisan hilms:make-block Poll --module=learning --interactivebin/artisan hilms:make-block Panel --module=blocks --containerbin/artisan hilms:make-block PanelTab --module=blocks --parent=panelThe 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:
- Fill in
settings(), with anAssetPickerunder the keyassetfor any file the block shows (Media has the steps) andRichText::html()for anything rich the view prints. No field is calledlook: that key is the look’s. - 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 fromContractViews::withSampleAssets()inblocks; 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?? nulland fall back to the default. - Narrow
contexts()if the block does not belong everywhere; answeronce()ordrawsPageHeading()if either is true of it, andnestable()false if it needs a row of its own and may not sit inside a container. - Choose a
category()and write adescription(): both are what the picker reads. - Add the label and the description to
<module>::blocks.<type>in both languages. A block of a module keeps its field labels beside them, underfields(pages::blocks.hero.fields.heading); the built-in blocks ofblocksshareblocks::fields. - Run the suite:
ViewContractTestwill already have picked the new view up from the registry. - 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.
Adding a pattern
Section titled “Adding a pattern”bin/artisan hilms:make-pattern CallToAction --module=pagesIt 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.