Sections
Unfold CMS includes a visual page builder for assembling dynamic page content from reusable section blocks. Each section type lives in its own folder inside the template, and admins arrange sections on pages through the admin panel at Pages.
Upgraded from: the old system stored content as blog posts linked via
section_placementsand used a singleconfig/sections.jsonper template. The new system uses one folder per section type and apage_sectionsdatabase table for placements.
Overview
The section system works on three levels:
- Template declares section types as folders under
templates/{tpl}/sections/{id}/, with a schema file and a Blade file per type. Pages declare which types they allow inconfig/pages.json. - Admin arranges sections on pages through the visual page builder at
/admin/pages. They add, reorder, enable/disable, and edit settings for each placement. - Template renders sections using
@renderPageSections('page')or direct@includefor site-wide singletons.
This separation means admins update content without touching code, while template developers control layout and design through self-contained section folders.
Section Folder Structure
Each section type is a folder:
templates/{tpl}/sections/{id}/
├── section.json # Schema: name, fields, settings, limits
├── {id}.blade.php # Blade template (receives $section)
└── seed.json # Demo content (used by SectionInstaller)
Example — the hero section in the atlas template:
templates/atlas/sections/hero/
├── section.json
├── hero.blade.php
└── seed.json
section.json
Defines the section's display name, field schema, and constraints.
{
"name": "Hero",
"description": "Headline, lead, primary/secondary buttons, and a brand illustration",
"block_type": "hero",
"icon": "Sparkles",
"max": 1,
"reusable": false,
"settings_only": true,
"supports_featured_image": false,
"version": 1,
"fields": [],
"settings": [
{ "key": "title", "type": "text", "label": "Title", "defaultValue": "Build a site you actually own", "col": 2 },
{ "key": "subtitle", "type": "textarea", "label": "Subtitle", "rows": 3, "col": 2 },
{
"key": "button_primary",
"type": "boolean",
"label": "Primary Button",
"defaultValue": true,
"children": [
{ "key": "button_primary_label", "type": "text", "label": "Label", "defaultValue": "Get started" },
{ "key": "button_primary_url", "type": "url", "label": "Link", "defaultValue": "/register" }
]
},
{ "key": "illustration", "type": "file", "label": "Hero Image", "file": { "accept": "image/*", "maxSize": 3145728, "preview": true }, "col": 2 }
]
}
section.json Fields
| Key | Type | Description |
|---|---|---|
name |
string | Display name in admin |
description |
string | Help text shown in admin |
block_type |
string | Internal type identifier (matches folder name) |
icon |
string | Lucide icon name for the admin card |
max |
int | Maximum placements of this type per page |
reusable |
bool | Whether the same type can appear more than once |
settings_only |
bool | No items — settings form only |
supports_featured_image |
bool | Whether items can have images |
version |
int | Schema version (for future migrations) |
fields |
array | Per-item custom fields |
settings |
array | Section-level settings (shown alongside items, or the only interface for settings_only) |
seed.json
Provides demo content loaded by SectionInstaller when a template is installed. Format mirrors what the admin would save:
{
"settings": {
"title": "Build a site you actually own",
"subtitle": "Self-hosted, fast, and yours to shape.",
"button_primary": "1",
"button_primary_label": "Get started",
"button_primary_url": "/register"
}
}
For item-based sections, include an items array instead of (or alongside) settings.
Page Configuration
config/pages.json declares which pages exist and which section types each page allows.
{
"homepage": {
"label": "Homepage",
"description": "Sections displayed on your site's homepage",
"url": "/",
"allow": ["hero", "logos", "stats", "features", "testimonials", "faq", "cta"]
},
"contact": {
"label": "Contact Page",
"description": "Sections displayed on the contact page",
"url": "/contact",
"allow": ["contact_info", "contact_form", "contact_faq", "contact_map"]
},
"site": {
"label": "Site-wide",
"description": "Sections shown on every page.",
"url": null,
"allow": ["header", "footer"]
}
}
| Key | Type | Description |
|---|---|---|
label |
string | Human-readable page name shown in admin |
description |
string | Help text shown in the page builder |
url |
string|null | Page URL for the preview link. null for site-wide pages |
allow |
array | Section type IDs (folder names) permitted on this page |
Database Table
Placements are stored in the page_sections table. One row = one placement.
| Column | Description |
|---|---|
id |
Auto-increment primary key |
template |
Active template name |
location |
Page key (e.g. homepage) |
type |
Section type ID matching the folder name |
values |
JSON blob of all field values for this placement |
is_active |
Whether the section is visible on the public page |
sort_order |
Integer used to order placements within a page |
The old section_placements table (legacy system) remains in the schema but is no longer used for rendering.
Admin Interface
Navigate to Pages in the admin panel. Select a page to open the page builder:
- Add section — pick a type from the allow-list, it is added to the bottom of the page
- Edit section — click a placement to open its settings panel
- Reorder — drag and drop placements
- Toggle — enable or disable individual placements without deleting them
- Delete — remove a placement permanently
Changes take effect immediately (the page cache is purged on save).
Rendering Sections in Templates
Auto-render: @renderPageSections
For page-level sections, use @renderPageSections('page_key') in the page's Blade view. It loops all active placements in order and includes each type's Blade file with $section bound:
{{-- home.blade.php --}}
@extends(($templatePath ?? 'templates.atlas') . '.layout')
@section('content')
@renderPageSections('homepage')
@endsection
The directive resolves the correct section blade automatically:
templates/{tpl}/sections/{type}/{type}.blade.php
Manual include: site-wide singletons
Site-wide sections (header, footer) are included manually in the layout because they appear on every page, not just one:
{{-- layout.blade.php --}}
@php $headerSection = page_section('site', 'header') @endphp
@cmsEditWrap('site', 'header')
@include(($templatePath ?? 'templates.atlas') . '.sections.header.header', ['section' => $headerSection])
@endCmsEditWrap
Writing a section Blade file
The Blade file receives $section — a PageSection model. Read values with $section->get('key', $default):
{{-- sections/features/features.blade.php --}}
@php
$badge = $section->get('badge', '');
$title = $section->get('title', 'Features');
$subtitle = $section->get('subtitle', '');
$items = $section->get('items', []);
@endphp
<section class="py-20">
<div class="container mx-auto px-4">
@if($badge)
<span class="badge">{{ $badge }}</span>
@endif
<h2>{{ $title }}</h2>
@if($subtitle)
<p>{{ $subtitle }}</p>
@endif
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach($items as $item)
<div class="feature-card">
@if($item['icon'] ?? null)
{!! lucide_icon($item['icon'], 'h-6 w-6') !!}
@endif
<h3>{{ $item['title'] ?? '' }}</h3>
<p>{{ $item['body'] ?? '' }}</p>
</div>
@endforeach
</div>
</div>
</section>
Items are plain arrays — no Eloquent model. Access fields by key: $item['title'], $item['icon'], etc.
Media / image values
When a field is a file upload, the stored value is either a media library integer ID (uploaded via admin) or a URL string (seed data). Use section_media_url() to resolve either to a URL:
{{-- Resolves int media ID or raw URL, returns null when unset --}}
@php $logoUrl = section_media_url('site', 'footer', 'logo_light') @endphp
@if($logoUrl)
<img src="{{ $logoUrl }}" alt="Logo">
@endif
Helper Functions
page_sections($location)
Returns an ordered array of active PageSection models for the given page on the active template.
$sections = page_sections('homepage');
// Returns PageSection[] ordered by sort_order
page_section($location, $type)
Returns a single PageSection for one section type on a page, or null if not placed / not active.
$footer = page_section('site', 'footer');
$tagline = $footer?->get('tagline', '');
section_value($location, $type, $key, $default)
Shorthand for reading one value from a placement.
$tagline = section_value('site', 'footer', 'tagline', '');
$title = section_value('homepage', 'hero', 'title', 'Welcome');
section_media_url($location, $type, $key)
Resolve a media field value to a URL. Returns null when unset.
$logo = section_media_url('site', 'footer', 'logo_light');
Blade Directives
| Directive | Description |
|---|---|
@renderPageSections('page') |
Render all active placements for a page in order |
@cmsEditWrap('location', 'type') ... @endCmsEditWrap |
Wrap a manually-included section with page-builder edit attributes |
@cmsEditBridge |
Emit the page-builder bridge JS/CSS (call once near </body>) |
The @sectionEnabled and @cmsSection directives remain for templates that have not yet migrated to the new system.
Artisan Commands
# Validate all section schemas for a template
php artisan sections:check atlas
# Validate the default template
php artisan sections:check
sections:check reports missing section.json files, unknown field types, and schema errors. Run it after adding or editing sections.
Adding a New Section Type
- Create the folder:
templates/{tpl}/sections/{id}/ - Add
section.jsonwith the schema - Add
{id}.blade.phpthat renders$section - Add
seed.jsonwith demo values - Add
{id}to theallowlist inconfig/pages.jsonfor the relevant pages - Run
php artisan sections:check {tpl}to validate - In the admin page builder, add the section to a page to test it
Installing Seed Data
SectionInstaller reads seed.json for each section type and writes placements to page_sections. It is called during template installation:
app(\App\Services\Sections\SectionInstaller::class)->install('atlas', 'skip');
// mode: 'skip' = keep existing placements | 'replace' = overwrite
See the Template Development Guide for complete instructions.