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_placements and used a single config/sections.json per template. The new system uses one folder per section type and a page_sections database table for placements.

Overview

The section system works on three levels:

  1. 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 in config/pages.json.
  2. Admin arranges sections on pages through the visual page builder at /admin/pages. They add, reorder, enable/disable, and edit settings for each placement.
  3. Template renders sections using @renderPageSections('page') or direct @include for 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

  1. Create the folder: templates/{tpl}/sections/{id}/
  2. Add section.json with the schema
  3. Add {id}.blade.php that renders $section
  4. Add seed.json with demo values
  5. Add {id} to the allow list in config/pages.json for the relevant pages
  6. Run php artisan sections:check {tpl} to validate
  7. 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.