Laravel & PHP ← Back to blog

Block-Based Pages in Laravel: How This CMS Works

27 April 2026 · Matt

A walkthrough of the JSON block architecture behind the page builder — from the Filament form to the Blade renderer.

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()
Tip: The ->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.