Skip to content

Blocks

The blocks package turns a page into a tree of blocks that editors arrange, nest and write in place. A block is a PHP class: the fields its data validates against, optional slots for child blocks, and a render built from Lattice components. The editor renders every block with the same React components a Lattice page uses, so what an editor sees is what the page shows. Text, rich text, images and button labels are edited directly in the block; layout and style live in an inspector. The stored document renders back into a Lattice page through BlockView, or into plain HTML for a public route through BlockHtmlRenderer.

Terminal window
composer require lattice-php/blocks

The package ships its React editor as source and the lattice() Vite plugin compiles it into your bundle (see Component packages). Blocks and editors are picked up by discovery and TypeScript generation like every other Lattice definition. No-build apps use the precompiled module: run php artisan lattice:assets after installation.

Annotate a BlockDefinition with #[AsBlock]. The attribute carries what the library shows — label, icon, category, description, search keywords — so the class stays a declaration of fields and render.

use Lattice\Blocks\Attributes\AsBlock;
use Lattice\Blocks\BlockData;
use Lattice\Blocks\BlockDefinition;
use Lattice\Blocks\BlockSlots;
use Lattice\Blocks\Components\RichText;
use Lattice\Blocks\Enums\BlockCategory;
use Lattice\Form\Components\RichEditor;
use Lattice\Form\Components\Select;
use Lattice\Form\Components\TextInput;
use Lattice\Ui\Components\Button;
use Lattice\Ui\Components\Heading;
use Lattice\Ui\Components\Stack;
use Lattice\Ui\Enums\Gap;
use Lattice\Ui\Enums\Icon;
#[AsBlock('app.hero', label: 'Hero', icon: Icon::LayoutTemplate, category: BlockCategory::Layout)]
final class HeroBlock extends BlockDefinition
{
public function fields(): array
{
return [
TextInput::make('title')->required()->rules(['max:90']),
RichEditor::make('intro'),
TextInput::make('button_label'),
Select::make('button_target')->options(['/demo' => 'Demo', '/pricing' => 'Pricing']),
];
}
public function render(BlockData $data, BlockSlots $slots): Stack
{
return Stack::make()->gap(Gap::Medium)->schema([
Heading::make($data->string('title')->toString(), 1)->bind('title'),
RichText::make($data->document('intro'), 'Write an intro…')->bind('intro'),
Button::make($data->string('button_label')->toString())
->href($data->string('button_target')->toString() ?: '#')
->bind('button_label'),
]);
}
}

fields() are ordinary form fields: their rules validate the block’s data, their casts shape what render() receives as BlockData (a FormData with the usual typed readers plus document() for rich text). Every field is edited somewhere in the editor — where depends on bind().

->bind('field') on any component marks the spot in the render where that field’s value appears. The editor swaps the bound node for an inline control while everything else keeps rendering through the regular registry:

Field type In the editor
TextInput, Textarea Editable text in the component’s own typography
RichEditor via RichText A Tiptap editor with the block toolbar and the / menu
MediaPicker on an Image Click to pick or replace the image
Select, Toggle, NumberInput A popover at the element
Fields without bind() The inspector’s Content tab

Bound text edits update the canvas locally; unbound fields re-render the block through the editor’s signed endpoint. Outside the editor binding has no effect.

BlockData::editing() tells a render whether it targets the canvas. Use it to keep an empty spot visible while editing and drop it from the read-only output:

$caption === '' && ! $data->editing()
? null
: Text::make($caption)->bind('caption'),

Layout blocks declare slots for child blocks. A slot can restrict the block types it accepts and bound how many it holds; the editor enforces both while dragging and inserting, and publishing validates them again.

use Lattice\Blocks\Slot;
public function slots(?array $data = null): array
{
return [
Slot::make('content')->label('Content')->allows([ParagraphBlock::class, ImageBlock::class])->max(6),
];
}
public function render(BlockData $data, BlockSlots $slots): Stack
{
return Stack::make()->schema([$slots->render('content')]);
}

slots() receives the block’s data, so a block can derive its slots from a field — the built-in columns block does this with its count. When the count shrinks, children of a removed slot move to the last remaining one instead of disappearing.

Every block carries a generic BlockStyle — width, spacing, background, alignment, hide on mobile or desktop, and an anchor id — that the inspector edits and the renderer applies around the block. The block render never sees it. A block opts out of controls that make no sense for it:

public function supports(): BlockSupports
{
return BlockSupports::all()->without('background', 'align');
}

Paragraph, heading, list, quote, image, gallery, separator, spacer, section (one slot) and columns (two to four slots). Builtin::all() lists them for an editor definition; register your own blocks alongside them.

An editor owns one document: which blocks it offers, where the draft lives, how a draft becomes the published state. Persistence stays in your application — the definition is the seam.

use Lattice\Blocks\Attributes\AsBlockEditor;
use Lattice\Blocks\BlockDocument;
use Lattice\Blocks\BlockEditorDefinition;
use Lattice\Blocks\Builtin\Builtin;
use Lattice\Blocks\Exceptions\StaleRevision;
#[AsBlockEditor('app.pages')]
final class PagesEditor extends BlockEditorDefinition
{
private ?Page $page = null;
public function blocks(): array
{
return [HeroBlock::class, ...Builtin::all()];
}
public function load(): BlockDocument
{
return $this->page()->draft ?? BlockDocument::empty();
}
public function revision(): int
{
return $this->page()->revision;
}
public function saveDraft(BlockDocument $document, int $revision): int
{
$page = $this->page();
if ($page->revision !== $revision) {
throw new StaleRevision($page->revision, $revision);
}
$page->forceFill(['draft' => $document, 'revision' => $revision + 1])->save();
return $page->revision;
}
public function publish(BlockDocument $document, int $revision): int
{
$this->page()->forceFill(['published' => $document])->save();
return $revision;
}
public function previewUrl(): ?string
{
return route('pages.public', $this->page()->slug);
}
private function page(): Page
{
return $this->page ??= Page::findOrFail($this->contextInt('page'));
}
}

An empty page opens with one paragraph so writing can start at once. Override seedBlock() to open with another block, or return null for an empty canvas.

Store the document with the AsBlockDocument cast:

protected function casts(): array
{
return ['draft' => AsBlockDocument::class, 'published' => AsBlockDocument::class];
}

Render the editor on a page. It fills the viewport, so a layout without chrome fits best:

#[AsPage(route: '/pages/{page}/edit', layout: PageLayout::None, width: PageWidth::Full)]
final class PageEditorPage extends Page
{
public function render(PageSchema $schema, \App\Models\Page $page): PageSchema
{
return $schema->schema([
BlockEditor::use(PagesEditor::class, ['page' => $page->getKey()]),
]);
}
}

The context you pass is sealed into the component’s reference and re-applied on every endpoint call, exactly like a table or board.

The editor autosaves the draft a few seconds after the last change and when the tab hides. Every save carries the revision it started from; when saveDraft() throws StaleRevision, the editor offers to reload the newer draft or overwrite it. Publish validates every block strictly — field rules, slot rules, unknown types — and only then calls publish().

Patterns are ready-made block groups the library inserts in one go. Each insertion mints fresh block ids, so the same pattern can appear more than once.

use Lattice\Blocks\BlockNode;
use Lattice\Blocks\BlockPattern;
public function patterns(): array
{
return [
BlockPattern::make('hero-cta')
->label('Hero with call to action')
->description('A headline, intro and closing prompt.')
->icon(Icon::LayoutTemplate)
->blocks([
BlockNode::make('app.hero', ['title' => 'Your headline']),
BlockNode::make('app.cta', ['title' => 'Ready?', 'button_label' => 'Start']),
]),
];
}

A pattern whose root blocks the editor does not offer is left out of the library.

BlockView::document() renders the stored tree with the same components the editor used, wrapped in frames that apply each block’s style:

BlockView::document($page->published ?? BlockDocument::empty());

For output outside a Lattice page — a public marketing route, an email — implement html() on each block and render the document through BlockHtmlRenderer. Children arrive as markup through $slots->html(); the built-ins ship Blade views under the blocks:: namespace (php artisan vendor:publish --tag=lattice-blocks-views to override them).

public function html(BlockData $data, BlockSlots $slots): View|string
{
$title = $data->string('title')->toString();
return $title === '' ? '' : view('blocks.hero', [
'title' => $title,
'intro' => RichText::toHtml($data->document('intro')),
]);
}
Route::get('/p/{page:slug}', function (Page $page, BlockHtmlRenderer $renderer) {
abort_unless($page->published, 404);
return view('public.page', ['content' => $renderer->render($page->published)]);
});

An empty string renders nothing for that block; null means the block has no HTML form. The renderer then calls the fallback configured under lattice.blocks.html_fallback — a callable or an invokable class receiving the BlockNode — or throws MissingHtmlRenderer.

Each block’s HTML is wrapped in a frame whose classes come from StyleClassMap, the same map the editor canvas and the in-app view use. Override any entry for your theme and all three follow:

config/lattice.php
'blocks' => [
'style_classes' => [
'width' => ['content' => 'container-narrow', 'wide' => 'container-wide'],
'background' => ['muted' => 'bg-stone-100'],
],
],
config/lattice.php
'blocks' => [
'endpoint' => 'lattice/block-editors/{editor}',
'middleware' => ['web', 'auth'],
'html_fallback' => null,
'style_classes' => [],
],

The editor endpoint verifies the sealed reference before resolving the definition with the same context, and authorize() on the definition gates both the initial render and every save.

The editor’s strings ship with inline English defaults; the blocks namespace serves the bundled en/de translations through laravel-i18next (see Internationalization).

The breakdown measures the package’s built plugin — React and the framework runtime are external. Tiptap is bundled here for the inline rich-text editor.

158.7 KB gzipped · 584.1 KB raw JavaScript

DependencyRawGzipShare
@lattice-php/blocks74.3 KB25.1 KB15.8%
TipTap + ProseMirror439.1 KB125.7 KB79.2%
Bundler runtime54.3 KB3.3 KB2.1%
src7.2 KB1.9 KB1.2%
rope-sequence3.9 KB0.8 KB0.5%
use-sync-external-store1.9 KB0.7 KB0.5%
w3c-keyname1.7 KB0.6 KB0.4%
orderedmap1.7 KB0.4 KB0.3%
Other0.0 KB0.0 KB0.0%

Emitted JavaScript files

FileRawGzip
plugin.js584.1 KB158.7 KB

Open the full interactive treemap ↗ · Generated by Sonda 0.14.0 on Fri, 11 Sep 2026 17:20:09 GMT.