haringsrob/filament-page-builder
A page builder for Filament that lets you create and manage dynamic pages using reusable content blocks and customizable layouts. Build flexible templates directly in the Filament admin panel and render them anywhere in your Laravel app.
Install and scaffold
composer require haringsrob/filament-page-builder
php artisan vendor:publish --tag="filament-page-builder-config"
php artisan vendor:publish --tag="filament-page-builder-migrations"
php artisan migrate
The package ships a pages table and a Page model by default. If you want the builder on your own model (e.g. Post, LandingPage), skip the migration and add a JSON column instead:
$table->json('content')->nullable();
Wire the model
use Haringsrob\FilamentPageBuilder\Traits\HasPageBuilder;
class Page extends Model
{
use HasPageBuilder;
protected $casts = [
'content' => 'array',
];
}
The trait gives you the block serialization/rendering pipeline. Check the trait source for the exact method names (getPageBuilderBlocks(), renderPageBuilder()) — it's a small package and signatures have shifted between releases.
Add the builder to a Filament form — this is the first real "aha" moment:
use Haringsrob\FilamentPageBuilder\Forms\Components\PageBuilder;
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('title')->required(),
PageBuilder::make('content'),
]);
}
Create your first block. Blocks are plain classes that declare a form schema and a view:
namespace App\PageBuilder\Blocks;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Haringsrob\FilamentPageBuilder\Blocks\PageBuilderBlock;
class HeroBlock extends PageBuilderBlock
{
public static function getName(): string
{
return 'Hero';
}
public function getView(): string
{
return 'page-builder.blocks.hero';
}
public static function getFormSchema(): array
{
return [
TextInput::make('heading')->required(),
Textarea::make('subheading'),
];
}
}
Register it in config/filament-page-builder.php under blocks, or per-field via PageBuilder::make('content')->blocks([...]).
Render on the frontend:
<x-page-builder :model="$page" />
That's the full loop — form field, block registry, Blade render. If you can render one block end-to-end, you understand the package.
Where to look first, in order: the config file, PageBuilderBlock base class, the HasPageBuilder trait, then the PageBuilder form component. Everything else is Filament form/Livewire plumbing you already know.
Block-per-section, not block-per-field. The strongest pattern is one block per content section (Hero, RichText, ImageGallery, CTA, Testimonials) rather than a generic "field" block. Each block owns its own form schema and Blade view — this keeps views simple and makes the picker legible to editors.
Keep view data explicit. Pass only what the view needs by overriding the block's data method (or relying on the base class to hand the serialized array to the view). Views should never reach back into the model — treat blocks as dumb presenters:
{{-- resources/views/page-builder/blocks/hero.blade.php --}}
<section class="hero">
<h1>{{ $data['heading'] }}</h1>
@if($data['subheading'] ?? null)
<p>{{ $data['subheading'] }}</p>
@endif
</section>
Group and order blocks deliberately. Use the blocks() method on the field when a resource needs a subset, and the config file for globally available blocks. If you build a marketing site, expose marketing blocks on the LandingPage resource and editorial blocks on the Post resource — don't dump all 20 blocks on every form.
Frontend routing. For a CMS-style page, resolve slug → Page in a catch-all route and render with <x-page-builder>. For landing pages attached to other models (products, campaigns), render inline in the existing controller — the trait works on any model with the JSON column.
Reusable sub-blocks via nesting. Filament repeater/Builder fields work inside getFormSchema(). This is the escape hatch for repeatable items (slides, feature lists, FAQ rows) without registering forty block classes. Keep nesting shallow — one level is usually enough, and deep nesting makes the editor UX painful.
Translations. Multilingual sites usually keep a per-locale JSON column (content_en, content_fr) and swap the field name per Filament locale, or use a translatable cast. Either way, treat each locale's block list as independent — editors expect to reorder sections per language.
Preview and drafts. Add a status column and a preview_token to the model; the PageBuilder field reads/writes content regardless. Render the preview route with the same <x-page-builder> component against the draft record. Don't build a separate preview rendering path — divergence between preview and production is the most common page-builder bug.
Cache rendered HTML, not the blocks. Serializing blocks is cheap; rendering N views per request is not. Cache the final Blade output with a key derived from updated_at:
return Cache::remember("page.{$page->slug}.{$page->updated_at->timestamp}", 3600, fn () =>
view('pages.show', ['page' => $page])->render()
);
Testing. Because blocks are plain classes with a declared view and schema, you can unit-test getName() / getView() and feature-test the rendered output:
$this->get('/'.$page->slug)
->assertSee($page->content[0]['data']['heading']);
Add a test that iterates every registered block class and asserts the view file exists — catches dead references before editors do.
Spatie Media Library integration. For image blocks, store the media ID in the block data and resolve it in the block class (getFirstMedia($data['media_id'])). Avoid storing URLs — they break on storage changes and CDN migration.
Block data is untyped. Everything round-trips through JSON, so numbers, booleans, and nested arrays come back as the shape you saved — but if a form field is removed from a block's schema, old data lingers silently. Add a migration or a cleanup command when you retire block fields, or editors will wonder why the old subheading still appears on the frontend.
Config caching bites. Blocks registered in config/filament-page-builder.php are cached by config:cache. After adding a block class locally, php artisan config:clear — otherwise it won't appear in the picker and you'll spend ten minutes blaming the package.
View resolution is strict. getView() must return a valid view path. If the view is missing, the builder renders nothing (or throws depending on version) — it does not fall back to a stub. Publish the package views (--tag="filament-page-builder-views") if you want to override the wrapper, but keep block views in your app.
Livewire + uploads. Image uploads inside blocks use Filament's standard FileUpload. In Filament v3 you must configure the disk and, for S3, the temporary upload directory. If previews 404 after upload, it's almost always a missing visibility: 'public' on the disk config, not the page builder.
Ordering and identity. Reordering blocks in the admin UI mutates array order in place. If you write custom code that consumes $page->content, never assume index 0 is the hero — filter by block type:
$hero = collect($page->content)->firstWhere('type', 'hero-block');
Filament version pinning. Last release was September 2024; Filament v3 iterates quickly. Pin both filament/filament and haringsrob/filament-page-builder in composer.json and test upgrades of Filament in a branch. A minor Filament bump can change form component signatures that block schemas rely on.
Extending without forking. The clean extension points are: the block base class (override getName, getIcon, getView, add helpers), the registered blocks array (swap a subclass), and the published views (wrapper markup). Avoid editing files in vendor/ — the block registry config gives you enough control to compose behavior without touching package code.
Debugging an empty render. Walk down the chain: (1) is content actually populated in the DB? (2) is the block type still in the registry? (3) does the block's view exist? (4) is the wrapper component receiving the right model? Ninety percent of "the builder isn't working" reports resolve at step 1 or 2.
Permissions. The builder is a Filament form field — it respects the resource's canEdit / policy. If editors can see blocks but not save, check the Pages policy and the JSON column's mass-assignability ($fillable includes content).
Don't over-abstract. It is tempting to build a block registry layer on top of the package's registry, a block settings panel, a versioning system. Resist. The package is intentionally thin; the fastest path to a working editorial product is one block class per section, one Blade view per block, and a straightforward cache on the rendered page.
How can I help you explore Laravel packages today?