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.

Filament 5.x PHP 8.3+ No build step
Source on GitHub ยท MIT licence

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, plus container-type on .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.

Design principles

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.

PieceWhoseWhat it is
Block classesYoursOne class per block type, implementing PageBlock
Blade componentsYoursOne per block type โ€” your markup, your CSS
The model & migrationYoursAny model with a JSON column cast to array
BlockRegistryPackageThe single source of truth for which types exist
DesignPagePackageThe canvas, as an abstract Filament resource page
BlockStateNormaliserPackageReconciles Filament form state with the stored shape
Canvas JS & CSSPackageRegistered 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": {}
  }
]
KeyMeaning
idStable 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.
typeThe machine name from your block’s type(). Never change one once content exists.
dataWhatever that block’s Filament schema produces, in its stored shape โ€” rich text as an HTML string, uploads as a plain path.
parentThe id of the container this block sits in, or null for a root.
slotWhich well of that container โ€” col-0, col-1, …
positionOrder among siblings in that slot.
settingsStyle tokens from the inspector: names, not CSS.
Extra keys are preserved

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'),
        ];
    }
}
MethodReturnsNotes
type()stringMachine name written into the JSON. Permanent.
label()stringShown in the palette and on each block’s header bar.
icon()?stringA Heroicon name, or null.
schema()arrayFilament components. Used by both the inspector and the form editor.
view()stringBlade component name, e.g. blocks.hero.
fileFields()arrayField names holding uploads. See below.
isVisible()boolWhether 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>
One component, three places

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.

TypeWhat it is
sectionA row of 1โ€“4 columns. Other blocks drop into a column.
textA text box you drop anywhere and type into.
imageAn image plus alt text.
buttonA labelled link.
spacerVertical space, token-sized.
dividerA 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>
One component, two contexts

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

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

If a block renders unescaped output

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:

FieldWhile editingOnce stored
RichEditorA TipTap document array ({"type": "doc", โ€ฆ})An HTML string
FileUploadAn array โ€” a wrapped path, or uuid => TemporaryUploadedFileA 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>
Inline, not an iframe

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 (or filament:upgrade) publishes them. filament:optimize does 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.

Assets load panel-wide

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

MethodEffect
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).

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

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