If you've ever wanted a Shopify-style page editor inside your Laravel app — drag-and-drop sections, live preview, multi-theme support, all powered by plain Blade views — that's exactly what we built.
coderstm/laravel-page-builder is a JSON-driven page composition system for Laravel 11 and 12. It gives your users a visual editor while keeping your developer workflow exactly as it should be: Blade files, PHP classes, and zero magic.
The Problem
Most Laravel page builders fall into one of two traps:
- Too much magic — custom DSLs, opaque rendering pipelines, or tight coupling to a specific frontend stack.
- Too rigid — pre-baked HTML structures that fight your design system the moment you need to deviate.
We wanted something that felt native to Laravel: Blade views for rendering, JSON for storage, and a clean PHP architecture underneath.
How It Works
Sections and Blocks Are Just Blade Views
A section is a Blade file that starts with a @schema() directive. That's it.
@schema([
'name' => 'Hero',
'settings' => [
['id' => 'title', 'type' => 'text', 'label' => 'Title', 'default' => 'Welcome'],
['id' => 'subtitle', 'type' => 'text', 'label' => 'Subtitle', 'default' => 'Build something great'],
['id' => 'bg_color', 'type' => 'color', 'label' => 'Background'],
],
])
<section {!! $section->editorAttributes() !!} style="background: {{ $section->settings->bg_color }}">
<h1>{{ $section->settings->title }}</h1>
<p>{{ $section->settings->subtitle }}</p>
</section>
Drop this file in resources/views/sections/hero.blade.php and it's immediately available in the editor. No registration, no YAML config, no artisan generate commands — the registry scans it automatically.
Blocks follow the exact same pattern:
@schema([
'name' => 'Card',
'settings' => [
['id' => 'title', 'type' => 'text', 'label' => 'Title', 'default' => 'Card Title'],
['id' => 'image', 'type' => 'image', 'label' => 'Image'],
],
])
<div {!! $block->editorAttributes() !!}>
<img src="{{ $block->settings->image }}" alt="">
<h3>{{ $block->settings->title }}</h3>
</div>
Container sections accept blocks via @blocks($section):
<section {!! $section->editorAttributes() !!}>
@blocks($section)
</section>
Pages Are Stored as JSON
When an editor saves a page, it writes a JSON file to disk:
{
"sections": {
"hero": {
"type": "hero",
"settings": {
"title": "Welcome to Our Store",
"bg_color": "#1a1a2e"
}
},
"features": {
"type": "feature-grid",
"settings": {},
"blocks": {
"card-1": { "type": "card", "settings": { "title": "Fast" } },
"card-2": { "type": "card", "settings": { "title": "Reliable" } }
},
"order": ["card-1", "card-2"]
}
},
"order": ["hero", "features"]
}
No database tables for content. JSON on disk means:
- Version-controllable page content
- Fast reads with no query overhead
- Easy to seed, import, or diff
The Rendering Pipeline
The architecture follows a strict five-layer dependency flow:
Schema → Registry → Components → Renderer → Services
-
Schema — Immutable
readonlyvalue objects describing section/block structure - Registry — Singleton scanners that discover and cache schemas from Blade files
-
Components — Hydrated
SectionandBlockruntime objects built from JSON + schema - Renderer — The single entry point for all HTML output; never bypassed
-
Services — High-level orchestrators (
PageService,PageStorage,PageRenderer)
Dependencies flow downward only. A Renderer never calls a Service. A Schema never calls a Registry. This makes the codebase easy to trace and test.
The Visual Editor
The editor is a React SPA with an iframe live preview. Changes sync to the preview in real time — no page refresh, no manual save to see output.
What the editor gives you out of the box:
- Drag-and-drop section and block reordering
- Settings panel with 21+ field types: text, color, image, icon, rich text, select, toggle, range slider, and more
- Inline text editing directly on the preview
- Media library with pluggable storage backends (local, S3, Cloudflare R2, DigitalOcean Spaces)
- SEO panel — title, description, keywords per page
- Theme settings — global CSS variables (colors, fonts, spacing) editable in the sidebar
The editor is injected via a single route and activates only when ?pb-editor=1 is present. In production, $section->editorAttributes() returns an empty string — zero overhead.
JSON Templates — Shopify-Style Fallbacks
Not every page needs a custom layout. Templates let you define default structures for pages that don't have their own JSON or Blade view:
{
"layout": "page",
"wrapper": "main#page-main.container",
"sections": {
"hero": {
"type": "hero",
"settings": {
"title": "{{ $page->title }}"
}
},
"content": { "type": "page-content" }
},
"order": ["hero", "content"]
}
Drop this in resources/views/templates/default.json, assign $page->template = 'default', and every page using that template renders with the same structure — settings pre-filled from the page model.
The wrapper key accepts a CSS selector: main#id.class[attr=value]. The renderer builds the wrapping element from it automatically.
Multi-Theme Support
The package integrates with qirolab/laravel-themer. Each theme can:
- Register its own sections and blocks from
themes/{name}/views/sections/andblocks/ - Override app templates by shadowing
templates/{name}.json - Define global settings schema (colors, fonts, spacing) that feed into CSS variables
Sections can accept any theme block using the @theme wildcard:
@schema([
'name' => 'Content Row',
'blocks' => [
['type' => '@theme'], // accepts any registered block from the active theme
],
])
Themes can shadow without modifying. If a theme ships hero.blade.php, it takes precedence over the app's hero.blade.php — no changes to the app required.
Recursive Block Nesting
Blocks nest to any depth. A row block can contain column blocks, which can contain card blocks, which can contain icon blocks. The renderer traverses the tree recursively, and the editor renders nested drag-and-drop handles at every level.
Block references in a section schema work in three ways:
| Entry | Resolved as |
|---|---|
['type' => 'row'] |
Theme block — looked up via BlockRegistry
|
['type' => '@theme'] |
Wildcard — any registered block |
['type' => 'item', 'name' => 'Custom Item'] |
Local definition — used as-is |
The distinction matters: adding a name key to a bare type reference changes it from a registry lookup to a local definition.
Per-Page Layout Zones
Pages can define header and footer sections that render outside the main sortable content:
{
"header": {
"sections": {
"nav": { "type": "navigation" }
},
"order": ["nav"]
},
"sections": {
"hero": { "type": "hero" }
},
"order": ["hero"]
}
Rendered via @sections('header') in the Blade layout. Editors configure them in a dedicated panel — they don't appear in the main drag-and-drop list.
Adding a New Field Type
The 21 built-in types cover most needs, but adding a custom type doesn't touch the core engine. Create a React component under resources/js/components/settings/, register it in FieldRegistry.ts, and it's available in any section's @schema() definition.
Installation
composer require coderstm/laravel-page-builder
php artisan vendor:publish --tag=pagebuilder-config
php artisan vendor:publish --tag=pagebuilder-views
php artisan migrate
Then create your first section in resources/views/sections/hero.blade.php and visit /page-builder in your app.
What's Next
We're actively working on:
- Revision history with diff view
- More built-in section types (pricing, testimonials, FAQ)
- Headless mode — JSON output for decoupled frontends
Links
- GitHub: github.com/coders-tm/laravel-page-builder
-
Packagist:
composer require coderstm/laravel-page-builder
If you've built something on top of this or have questions about the architecture, drop them in the comments. Happy to dig into any layer of the system.
Top comments (0)