The Concept
Rather than a rigid page template, each page is built from an ordered array of blocks stored as JSON. Each block has a type (e.g. hero, text, blog_types) and a data object with the fields for that type.
The Page Model
// app/Models/Page.php
class Page extends Model
{
protected $fillable = ['title', 'slug', 'blocks'];
protected $casts = [
'blocks' => 'array',
];
}
Casting blocks to array means Laravel automatically JSON-encodes on save and decodes on read — no manual json_encode/json_decode needed.
The Blade Renderer
{{-- resources/views/pages/show.blade.php --}}
@foreach ($page->blocks as $block)
@include("blocks.{$block['type']}", ['data' => $block['data']])
@endforeach
Each block type maps to a file in resources/views/blocks/. Adding a new block type is just adding a new Blade file — no controller changes needed.
The Filament Form
In the admin panel, pages are edited with a Filament Repeater. Each item has a type Select and a dynamic Group that shows different fields based on the selected type.
Repeater::make('blocks')
->schema([
Select::make('type')
->options([
'hero' => 'Hero Banner',
'text' => 'Text Block',
'blog_types' => 'Blog Categories',
'blog_posts' => 'Recent Blog Posts',
])
->live()
->afterStateUpdated(fn (Set $set) => $set('data', [])),
Group::make()
->schema(fn (Get $get): array => match ($get('type')) {
'hero' => [
TextInput::make('data.title')->required(),
TextInput::make('data.subtitle'),
],
'text' => [
RichEditor::make('data.content')->required(),
],
default => [],
}),
])
->reorderable()
->collapsible()
->live() on the Select re-renders the form reactively whenever the type changes. The afterStateUpdated clears the data object so stale fields from the previous type don't leak into the saved JSON.