Filament Page Builder
A drag-and-drop visual page builder for Filament, storing page content as an ordered array of typed blocks in a single JSON column.
Overview #
Filament’s Builder field is an excellent structured editor, but it is a form:
a vertical stack of collapsible panels. You cannot drop a block where you want it on the page, and you
cannot see the layout you are actually building.
This package adds a canvas alongside that form. Both surfaces read and write the same JSON, so neither owns the content โ an editor can start a page in the form, arrange it on the canvas, and go back again. If you later decide the canvas was a mistake, removing it costs you nothing.
What the canvas does today
- A full-screen Design page — the editor owns the viewport, not a squeezed Filament content column
- A palette grouped by kind: Layout, Content, Design, then your own blocks
- Drag from the palette onto the page, or into a column
- Drag the handle on a rendered block to reorder it, including across sections
- A document outline (Structure) that follows the tree
- Click a block to select it; edit it in an inspector on the right, using that block’s own Filament schema
- A Style tab of design tokens โ padding, background, width, alignment โ not raw CSS
- Desktop / tablet / mobile preview widths on the toolbar (a
max-width, pluscontainer-typeon.fpb-canvas) - Palette search, block descriptions, breadcrumbs, and a Hidden list for orphans and columns that were removed
- An allowlisted Embed block, and an optional anchor id on every block
- Type into the page itself โ a block can open its own text fields for editing in place, and the inspector follows the caret
- Duplicate and delete, with a confirmation before anything holding content goes. Duplicating a section copies the blocks inside it.
- Undo and redo, keyboard shortcuts, and a guard against leaving with unsaved work
- Blocks render with your real Blade components and your real CSS, not a stand-in
- Everything is held in memory and written on an explicit Save, so a drag never waits on the server
- On a phone or a narrow window, Design mode is a one-panel editor: Blocks, Page and Settings in a bottom dock, a floating add button, and tap-to-insert (native drag is a desktop gesture)
What it deliberately does not do
No free positioning, no pixel-level canvas. Blocks stay the unit of layout โ typed components in a tree, not a free-form artboard. A constrained palette that always produces on-brand output is the point. Free positioning mostly gives editors new ways to break your design.
The package owns the mechanism; your application owns the content. Blocks are classes in your app, free to query your models and render your markup. The package ships no model and no migration.
One registry, one set of components. The form, the canvas and your public renderer all resolve through the same registry, so a block cannot mean different things in each.
Consumers never run a bundler. The canvas JavaScript is shipped ready to serve and is registered under the package’s own namespace, so it never touches your application’s build. This was written for hosts with no Node installed. The package may ship prebuilt files of its own; you still install it with Composer and nothing else.
Unknown block types are skipped, not fatal. Content outlives schema changes.
How it fits together #
Three things you write, four things the package provides.
| Piece | Whose | What it is |
|---|---|---|
| Block classes | Yours | One class per block type, implementing PageBlock |
| Blade components | Yours | One per block type โ your markup, your CSS |
| The model & migration | Yours | Any model with a JSON column cast to array |
BlockRegistry | Package | The single source of truth for which types exist |
DesignPage | Package | The canvas, as an abstract Filament resource page |
BlockStateNormaliser | Package | Reconciles Filament form state with the stored shape |
| Canvas JS & CSS | Package | Registered under the package’s own asset namespace |
Everything specific to your site stays on your side of that line. The package never learns your table name, your permission system, or your brand.
Installation #
Requirements
- PHP 8.3 or newer
- Filament 5.x (tested against 5.8)
- No Node, npm or build step โ at any point
composer require carljanzell/filament-page-builder
The service provider is auto-discovered. Publish the canvas assets โ your deploy almost certainly runs this already:
php artisan filament:assets
The model
Point the builder at any model with a JSON column cast to array. There is no base class to
extend and no migration to run.
app/Models/Page.php
class Page extends Model
{
protected function casts(): array
{
return ['blocks' => 'array'];
}
}
The stored shape #
One JSON column holds a flat array. Nesting is expressed with parent, slot and
position on that same list, so a move is “set these three keys” rather than a
path-splice of nested arrays. Pages written before nesting existed have none of those keys and become a
list of roots โ opening one does not change what it looks like.
[
{
"id": "s",
"type": "section",
"data": { "columns": 2, "ratio": "1-1" },
"parent": null,
"slot": null,
"position": 0,
"settings": { "padding": "lg" }
},
{
"id": "t",
"type": "text",
"data": { "body": "Hello" },
"parent": "s",
"slot": "col-0",
"position": 0,
"settings": {}
}
]
| Key | Meaning |
|---|---|
id | Stable identity, so reordering does not change what a block is. Minted automatically the first time a record is opened on the canvas, then persisted. You never set it. |
type | The machine name from your block’s type(). Never change one once content exists. |
data | Whatever that block’s Filament schema produces, in its stored shape โ rich text as an HTML string, uploads as a plain path. |
parent | The id of the container this block sits in, or null for a root. |
slot | Which well of that container โ col-0, col-1, … |
position | Order among siblings in that slot. |
settings | Style tokens from the inspector: names, not CSS. |
Anything you attach to a block beyond these keys is carried through a load and a save untouched. The canvas merges, it does not rebuild.
Defining a block #
A block is a class implementing PageBlock. Everything is static: the registry needs to
describe a block type without instantiating one.
app/PageBlocks/HeroBlock.php
<?php
namespace App\PageBlocks;
use CarlJanzell\FilamentPageBuilder\Contracts\PageBlock;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\TextInput;
class HeroBlock implements PageBlock
{
public static function type(): string { return 'hero'; }
public static function label(): string { return 'Hero'; }
public static function icon(): ?string { return 'heroicon-o-photo'; }
public static function view(): string { return 'blocks.hero'; }
public static function fileFields(): array { return ['image']; }
public static function isVisible(): bool { return true; }
public static function schema(): array
{
return [
TextInput::make('heading')
->required()
->live(onBlur: true),
FileUpload::make('image')
->image()
->disk('public')
->directory('pages'),
];
}
}
| Method | Returns | Notes |
|---|---|---|
type() | string | Machine name written into the JSON. Permanent. |
label() | string | Shown in the palette and on each block’s header bar. |
icon() | ?string | A Heroicon name, or null. |
schema() | array | Filament components. Used by both the inspector and the form editor. |
view() | string | Blade component name, e.g. blocks.hero. |
fileFields() | array | Field names holding uploads. See below. |
isVisible() | bool | Whether the current user may author this type. |
Why fileFields() is explicit
An upload is an array while it sits in form state but a plain path string once stored. To render a live preview the package has to reconcile the two โ and it cannot infer which fields those are, because a map of strings is indistinguishable from a repeater item. So you declare them. Get this wrong and the preview shows a broken image; nothing is lost from the database.
The matching Blade component
Each block needs a component at the name view() returns. It receives one prop, data.
Read every field defensively โ content written under an older schema will still be in the database.
resources/views/components/blocks/hero.blade.php
@props(['data' => []])
<section class="hero">
<h1>{{ $data['heading'] ?? '' }}</h1>
@if (filled($data['image'] ?? null))
<img src="{{ Storage::disk('public')->url($data['image']) }}" alt="">
@endif
</section>
The same component renders on your public site, in the canvas, and in the form
editor’s live preview. That is what stops the surfaces from drifting apart โ but it also means it must
survive being handed half-finished data mid-edit. Defensive ?? everywhere.
Sections and columns #
The package ships layout primitives so a new panel already has something to compose
with. They appear in the palette automatically. Register a class with the same type() to
replace one, or pass includeLayoutBlocks(false) to hide them all.
| Type | What it is |
|---|---|
section | A row of 1โ4 columns. Other blocks drop into a column. |
text | A text box you drop anywhere and type into. |
image | An image plus alt text. |
button | A labelled link. |
spacer | Vertical space, token-sized. |
divider | A horizontal rule. |
Your own blocks drop into those columns the same way. A section may sit inside a section, up to five levels deep โ enough for the layouts people actually build, not enough to bury content by accident.
A container is any block that implements Container and declares slots(). The
block’s own Blade view asks PageBuilder::slot($name) for each well; on the canvas that
call wraps a drop target, on the public page it is just the children.
use CarlJanzell\FilamentPageBuilder\PageBuilder;
<section class="fpb-section" data-fpb-ratio="{{ $data['ratio'] ?? '1-1' }}">
@foreach (PageBuilder::slotNames() as $name)
{!! PageBuilder::slot($name) !!}
@endforeach
</section>
Style tokens
The inspector’s Style tab writes names onto settings, emitted as
data-fpb-padding="lg" on the wrapper. Point the plugin at your own token set so the knobs
map onto your stylesheet:
FilamentPageBuilderPlugin::make()
->styleTokens([
'padding' => ['none' => 'None', 'sm' => 'Small', 'md' => 'Medium', 'lg' => 'Large'],
'background' => ['none' => 'None', 'maroon' => 'Maroon', 'forest' => 'Forest'],
'width' => ['narrow' => 'Narrow', 'default' => 'Default', 'wide' => 'Wide', 'full' => 'Full'],
'align' => ['start' => 'Start', 'center' => 'Center', 'end' => 'End'],
])
An editor cannot produce a 13px lime heading, because the knobs are this list, not a colour picker.
Editing on the page #
A block can open its own fields for editing directly on the canvas, so an editor clicks the heading and types into the heading rather than hunting for it in the panel on the right. Two things are needed, and they are deliberately separate.
1. The block declares what is editable
Implement InlineEditable alongside PageBlock. Existing blocks that do not are
untouched and keep being edited in the inspector.
use CarlJanzell\FilamentPageBuilder\Contracts\InlineEditable;
use CarlJanzell\FilamentPageBuilder\Contracts\PageBlock;
use CarlJanzell\FilamentPageBuilder\Editable;
class HeroBlock implements PageBlock, InlineEditable
{
public static function editables(): array
{
return [
'heading' => Editable::text()->placeholder('Write a heading'),
'subheading' => Editable::text()->multiline(),
];
}
// type(), label(), schema(), view() โฆ as before
}
2. The markup says which element that is
In the block’s own Blade component, mark the element with @editable:
<section class="blk-hero">
<h1 @editable('heading')>{{ $data['heading'] ?? '' }}</h1>
<p @editable('subheading')>{{ $data['subheading'] ?? '' }}</p>
</section>
The directive expands to editing attributes while the canvas is rendering and to nothing at all anywhere else. Your public page ships exactly the markup above, minus the attributes. There is no second renderer for the editor, so there is nothing for the two to drift apart over.
The declaration is the authority, not the markup
@editable('secret') on a field the block never listed in editables() emits nothing.
The canvas is a public Livewire surface, so setBlockField() independently refuses:
- any field the block has not declared editable;
- any value of the wrong kind for that field;
- any block whose
isVisible()says this user may not author it; - any block id that is not actually on the page.
How it behaves
| Kind | On the canvas |
|---|---|
Editable::text() | contenteditable="plaintext-only". Enter commits and blurs. |
Editable::text()->multiline() | As above, but Enter inserts a line break. |
Editable::richText() | Declared, but still edited in the inspector. See limitations. |
Edits commit on blur, not per keystroke: a round trip per character would have Livewire re-render the block under the caret. Escape reverts the field, and focusing one selects its block so the inspector follows the caret. A morph hook keeps renders triggered by anything else off whatever is being typed into.
placeholder() is shown when the field is empty, so an unfilled block is still something you can
click into rather than a zero-height element.
Registering blocks #
Register the plugin on a panel and hand it your block classes. Order here is the order they appear in the palette.
app/Providers/Filament/AdminPanelProvider.php
use CarlJanzell\FilamentPageBuilder\FilamentPageBuilderPlugin;
public function panel(Panel $panel): Panel
{
return $panel
// โฆ
->plugins([
FilamentPageBuilderPlugin::make()
->blocks([
HeroBlock::class,
RichTextBlock::class,
CtaBlock::class,
CustomHtmlBlock::class,
])
->canvasStylesView('filament.pages.canvas-styles'),
// Section, text, image, button, embed, spacer and divider are registered
// unless you pass ->includeLayoutBlocks(false)
]);
}
A class that does not implement PageBlock throws an InvalidArgumentException at
registration, so a typo fails loudly at boot rather than quietly at render.
Adding the canvas #
DesignPage is abstract. Subclass it and bind it to your resource:
app/Filament/Admin/Resources/Pages/Pages/DesignPage.php
use CarlJanzell\FilamentPageBuilder\Filament\Pages\DesignPage as BaseDesignPage;
class DesignPage extends BaseDesignPage
{
protected static string $resource = PageResource::class;
}
The canvas is a dedicated full-screen editor. Filament’s sidebar, topbar and page heading
stay behind so the page itself is the workspace — palette, canvas and inspector, with a
slim bar for Back, preview widths and Save. The browser tab still reads
Design: {record}.
Then register the route on the resource:
public static function getPages(): array
{
return [
'index' => ListPages::route('/'),
'create' => CreatePage::route('/create'),
'edit' => EditPage::route('/{record}/edit'),
'design' => DesignPage::route('/{record}/design'),
];
}
And give editors a way in โ a header action on the edit page is the obvious place:
Action::make('design')
->label('Design')
->icon('heroicon-o-squares-2x2')
->url(fn (): string => PageResource::getUrl('design', ['record' => $this->record]))
Access control
The canvas authorises through your resource: it calls YourResource::canEdit($record) and
aborts with a 403 if that is false. Whatever policy or permission layer already guards editing guards the
canvas too โ you configure nothing.
Saving
Mutations apply in memory and mark the page dirty; nothing is written until the editor presses Save layout.
Navigating away with unsaved work prompts; deleting a block that holds content asks first. Undo lives for the life of the canvas session.
Keeping the form editor #
The canvas is an addition. Structured work โ slug, SEO fields, publish date โ belongs in a form,
and Filament’s Builder field is still the better tool for bulk text entry. Drive that field
from the same registry so the two surfaces cannot disagree:
app/Filament/Admin/Resources/Pages/Schemas/PageBlocks.php
use CarlJanzell\FilamentPageBuilder\BlockRegistry;
use Filament\Forms\Components\Builder\Block;
class PageBlocks
{
/** @return array<int, Block> */
public static function all(): array
{
return array_values(array_map(
fn (string $block): Block => Block::make($block::type())
->label($block::label())
->icon($block::icon())
->visible(fn (): bool => $block::isVisible())
->schema($block::schema()),
app(BlockRegistry::class)->all(),
));
}
}
Then in your form schema:
Builder::make('blocks')->blocks(PageBlocks::all())
Add a block class once and it appears in both places. That is the whole reason the registry exists.
Rendering on your public site #
Once a page has sections, a naive @foreach of the stored array will also print the children
as extra top-level blocks. Use the shipped tree renderer:
<x-page-builder::blocks :blocks="$page->blocks" />
It walks roots only, fills each container’s slots, and skips unknown types โ a block you removed
last year leaves a hole in one page, not a 500 across the site. Style tokens are emitted as
data-fpb-* on a .fpb-el wrapper so your stylesheet can honour them.
Pages that are still a flat list of roots keep working with a hand-rolled loop. The moment an editor drops a section, switch to the component.
Authorisation #
There are two independent layers, and it is worth being clear about which does what.
1. Can this user open the canvas at all?
Your resource’s canEdit(). Nothing to configure.
2. May this user author this kind of block?
The block’s own isVisible(). It is a callback rather than a role check so the package
never assumes how you do permissions โ spatie/laravel-permission, a gate, a plain column, whatever
you like:
public static function isVisible(): bool
{
return auth()->user()?->hasRole('super_admin') ?? false;
}
When isVisible() is false the block type is:
- โ hidden from the palette
- โ refused by
insertBlock(), even called directly over the wire - โ refused by
duplicateBlock() - โ withheld from the inspector โ the fields are not rendered, and the block’s stored content is left untouched rather than overwritten with empty state
Existing blocks of that type stay visible on the canvas, and can still be moved and deleted. Neither authors content, and an editor who can see a block on a page they own should be able to take it off.
A “custom HTML” block that renders {!! $data['html'] !!}
is a privilege in disguise: whoever can author it can put arbitrary markup and styles on your public site.
Gate it with isVisible() and treat that gate as a security boundary, not a UI tidiness feature.
State normalisation #
Two Filament fields hold a different shape while editing than the one they store, and both will break a renderer that assumes the stored shape:
| Field | While editing | Once stored |
|---|---|---|
RichEditor | A TipTap document array ({"type": "doc", โฆ}) | An HTML string |
FileUpload | An array โ a wrapped path, or uuid => TemporaryUploadedFile | A plain path string |
BlockStateNormaliser reconciles them, and the canvas applies it before rendering. Rich text is
resolved by shape and walked recursively, so editors nested inside repeaters are handled; uploads are resolved
by the field names your block declares in fileFields(). A file still mid-upload has no permanent
path, so it is previewed from Livewire’s temporary URL and the stored value is left alone.
You get this for free on the canvas. You need it explicitly if you build a live preview beside the form editor:
use CarlJanzell\FilamentPageBuilder\Support\BlockStateNormaliser;
$preview = app(BlockStateNormaliser::class)->normalise($rawFormState);
Styling the canvas #
The package styles the builder chrome โ palette, toolbar, block outlines, the drop marker. It knows nothing about how you style blocks. Point it at a view holding your design tokens and block stylesheet, and it is included inside the canvas so blocks render exactly as they do publicly:
FilamentPageBuilderPlugin::make()
->canvasStylesView('filament.pages.canvas-styles')
resources/views/filament/pages/canvas-styles.blade.php
<style>
/* your design tokens and block CSS, scoped under .fpb-canvas */
</style>
Blocks render in the same document as the panel. An iframe would give perfect CSS isolation but makes drag-and-drop across the boundary and Livewire state sync genuinely painful. The trade is that panel CSS can leak into your blocks โ scope your block styles under a wrapper class if that bites.
Chrome classes you can override
All prefixed fpb-: .fpb, .fpb-palette, .fpb-palette-item,
.fpb-canvas, .fpb-block, .fpb-block-bar, .fpb-block-body,
.fpb-drop-marker, .fpb-chrome, .fpb-toolbar-actions, .fpb-inspector, .fpb-empty.
The selected block carries data-selected="true".
Assets, and why there is no bundler #
This package was written for a production host with no Node and no npm. That constraint shaped it, and it is a feature: a package that makes consumers run a JS build is a worse package.
- Canvas JS and CSS are hand-written and shipped unminified.
- They are registered through
FilamentAsset::register([...], 'carljanzell/filament-page-builder'), namespaced to the package, so they never touch your application’s build. - Drag-and-drop uses the browser’s native HTML5 API. Filament bundles SortableJS but does not expose it as a public global, so relying on it would mean either a build step or a private API.
- Reactivity uses Alpine, which Filament already bundles.
php artisan filament:assets(orfilament:upgrade) publishes them.filament:optimizedoes not.
Reordering is resolved entirely in the browser and committed to Livewire in a single call per drop, so no drag gesture waits on a round trip.
They are registered without loadedOnRequest(), so the canvas JS and CSS
are present on every page of the panel, not only the canvas. Small enough not to matter today.
API reference #
FilamentPageBuilderPlugin
| Method | Effect |
|---|---|
blocks(array $classes) | Your block classes. Layout primitives are registered in front unless disabled. |
includeLayoutBlocks(bool $on = true) | Whether section, text, image, button, embed, spacer and divider appear. Default true. |
styleTokens(array $tokens) | The Style tab’s knobs. Default padding/background/width/align. |
canvasStylesView(?string $view) | A Blade view included inside the canvas, before the blocks. |
recordModel(string $model) | Stored for documentation and future scaffolding. The canvas does not read it — it edits the resource record. |
blocksAttribute(string $attr) | The JSON attribute holding the blocks. A record using HasBlocks may override it for itself. |
FilamentPageBuilderPlugin::get() | The registered instance, from anywhere. |
BlockRegistry
Resolve it from the container: app(BlockRegistry::class).
| Method | Returns |
|---|---|
all() | array<string, class-string> โ every registered block, keyed by type. |
visible() | Only those the current user may author. |
find(?string $type) | The class for a type, or null. |
has(?string $type) | Whether a type is registered. Not an authorisation check. |
isVisible(?string $type) | Whether the current user may author it. Use this to gate authoring. |
view(?string $type) | Blade component name, or null for an unknown type. |
fileFields(?string $type) | That block’s declared upload fields, or []. |
isContainer(?string $type) | Whether the type implements Container. |
slots(?string $type, array $data) | Slot names that instance currently exposes. |
defaults(?string $type) | Starting data for a freshly dropped block. |
category(?string $type) | Palette group. App blocks default to blocks. |
description(?string $type) | Optional one-line palette subtitle, or null. |
DesignPage
Livewire methods on the canvas, all callable from your own view overrides.
| Method | Effect |
|---|---|
moveBlock(string $id, int $to, ?string $parent = null, ?string $slot = null) | Reorder, or move into a container slot. |
insertBlock(string $type, ?int $at = null, ?string $parent = null, ?string $slot = null) | Insert at an index, or into a slot. A palette click with a selection inserts after it, or into its first column. Refused unless authorised. |
duplicateBlock(string $id) | Copy in place with a fresh id. Refused unless the type and every descendant is authorable. |
removeBlock(string $id) | Delete. No confirmation. |
revealGhost(string $id) | Move an orphan onto the page, or a hidden-slot child into the parent’s last visible column. |
selectBlock(?string $id) | Select a block and load it into the inspector. |
save() | Commit the inspector, refuse if updated_at changed elsewhere, write the column, notify. |
isSelectedBlockEditable() | Whether the inspector should show fields for the selection. |
Public properties: $blocks, $selectedId, $blockData,
$blockSettings, $blockAnchor, $savedBlocks,
$loadedUpdatedAt, $isDirty. Computed: $this->rootBlocks,
$this->renderableBlocks, $this->palette, $this->paletteGroups,
$this->structure, $this->ghosts, $this->selectionPath.
HasBlocks optional
A convenience trait for your model โ getBlocks() returns the array defensively, and
ensureBlockIds() stamps ids onto a raw array. The canvas does not require it;
it reads the attribute directly.
Known limitations #
Documented rather than hidden. Each of these is real today. Several earlier entries โ content destroyed on save, a hardcoded column name, one registry shared between panels, no undo, no test suite โ have since been fixed and are gone from this list.
Rich text is still edited in the inspector, not in place
Plain text fields are edited directly on the page. A RichEditor still opens on the right.
setBlockField() refuses richText writes until Filament’s TipTap bundle is
mounted in place. Do not ship a second HTML producer.
Inline editing is limited to top-level string fields
setBlockField() writes one named field on one block. Fields inside a repeater cannot be edited
in place, and only fields the block names in editables() can be written at all.
No draft, no revisions
Saving writes straight to the record the public site reads. There is no draft copy to promote and no history
to restore from โ undo lives only as long as the canvas is open. Save compares updated_at so a
second tab or the form editor cannot silently overwrite the other; you still have to reload after a conflict.
Undo history is per session, not per tab
The undo stack is held in the session so it costs the Livewire payload nothing. Use a server-side session driver (file, database, redis) โ thirty snapshots of a large page will overflow a cookie session. Two canvases open in two tabs share one session, so opening a second discards the first’s history.
The form editor is unsafe once a page is nested
Filament’s Builder has no tree. Cloning an item used to copy the id (the canvas now remints
it). Deleting a section still orphans its children โ they appear under Structure → Hidden. The canvas
warns before opening the form editor on a nested page. Prefer the canvas for anything with columns.
On a phone, drag-and-drop is off
Native HTML5 drag is a desktop gesture. Below 1280px the Design canvas uses tap-to-insert, up / down on the selected block to reorder siblings, and the bottom dock to reach Blocks and Settings. Nesting into a specific column still wants a wider window, or a section selected so a tap lands in its first column. SortableJS is not vendored.
JavaScript is untested
Drag, drop, inline commit and keyboard shortcuts have no browser tests. Renaming a Livewire method or a
data-* attribute breaks the canvas silently. Run scripts/check-drift.sh after those
edits, then click through a real panel.
Unregistered block types cannot be edited
They are no longer destroyed โ a retired type keeps its place and its content through a save, and can be moved or removed. But the canvas has no schema for it, so it shows a placeholder instead of the block.
Licence #
Open source under the MIT licence. Copyright © 2026 Carl Janzell. See LICENSE.