Building sections
The full section system: sections.json definitions, the render pipeline, admin-editable content, repeatable elements, and a complete worked example.
On this page
- Building sections
- The two halves of the system
- sections.json: the secs array and the definitions
- The render pipeline
- Reading admin-edited content: getContent
- Seed defaults: never render empty
- Failing safe: guard, and skip cleanly
- Worked example: a custom "announcement" section
- The crud model (FAQ-style lists)
- Checklist for every section you write
- Next
Building sections#
A section is a composable, admin-arrangeable content block — a hero, a features grid, an FAQ, a call-to-action band. Pages like the homepage don't hardcode their content; they render an ordered list of section slugs, and each slug maps to one Blade file. The operator reorders, edits, and populates those blocks from the admin page-builder without ever touching your theme's code. This article covers the whole system: the sections.json manifest, the render pipeline, writing a section Blade, making its text admin-editable, repeatable elements, and how a bad slug fails safe.
If you haven't read Theme anatomy yet, do that first — it explains where sections.json sits in the directory and how it relates to the two manifests.
The two halves of the system#
A section has two parts, in two places:
| Part | Lives in | Purpose |
|---|---|---|
| Definition | sections.json (a key per section) | Tells the admin page-builder what fields this section exposes to edit |
| View | sections/{slug}.blade.php | Renders the block, reading the admin-edited values (or a seed default) |
The slug ties them together. A definition keyed features in sections.json is rendered by sections/features.blade.php, and its edited content is stored under the key features.content.
sections.json: the secs array and the definitions#
sections.json is a single JSON object with two kinds of top-level keys.
1. `secs` — the ordered render array. This is the default render order: an array of section slugs. Order in the array is order on the page.
{
"secs": [
"hero",
"features",
"how_it_works",
"pricing",
"testimonials",
"faq",
"bottom_cta"
],
...
}2. Every other key is a section DEFINITION. Each one describes the fields the page-builder should render for that block:
"features": {
"name": "Features Section",
"builder": true,
"content": {
"heading": "text",
"subheading": "textarea",
"description": "textarea"
},
"element": {
"title": "text",
"icon": "text",
"description": "textarea"
}
}The definition object supports these fields:
| Field | Type | What it does |
|---|---|---|
name | string | Human label shown in the admin builder. |
builder | bool | true = editable in the page-builder. |
content | object | The section's single editable fields, as {field: type}. Types: text, textarea, image, select-pages. |
element | object | Schema for a repeatable row (the features grid, testimonial cards). Same {field: type} shape. Produces many rows under {slug}.element. |
crud + inputs | bool + object | Alternative repeatable model with its own managed CRUD screen; inputs is the row schema. Used by faq. |
no_selection | bool | Marks a definition that is configuration rather than a placeable page block (e.g. contact, branding) so it isn't offered in the section picker. |
content gives you a single stored object per section (headings, a button label, a switch). element gives you a list of stored objects (each card in a grid). A section can use one, both, or neither.
The render pipeline#
Nothing renders a section directly. A page hands an object with a ->secs array to the shared renderer, which loops it. The homepage (home.blade.php) does exactly this:
@include(activeTemplate() . 'partials.render-sections', [
'sections' => (object) ['secs' => $homeSecs],
])partials/render-sections.blade.php is the one tolerant loop for the whole theme. Its core is:
@foreach($renderSecs as $sec)
@php $secView = templateViewName('sections.' . $sec); @endphp
@if(view()->exists($secView))
@include($secView)
@endif
@endforeachThree properties matter, and you get them for free by routing through this partial:
- A missing or renamed slug is skipped, not fatal. The
view()->exists()guard (equivalently@includeIf) means a slug with no matching Blade file is silently dropped. A typo insecsnever white-screens the page. - An empty `secs` renders a neutral empty state, not a blank page and not a fabricated marketing block.
- Each slug resolves through the active theme via
templateViewName('sections.'.$sec)→themes.{theme}.sections.{sec}.
So to "add a section to the homepage" an operator just adds its slug to the order; to build a new section you ship a definition and a Blade file, and it becomes placeable.
activeTemplate()andtemplateViewName()are the deprecated aliases ofactiveTheme()andthemeViewName(). New code should prefer thetheme*names — see Helper & template reference. The reference theme still uses thetemplate*spellings internally.
Reading admin-edited content: getContent#
Inside a section Blade you read the operator's edits with the getContent() helper. It queries the frontends table scoped to the active theme name, keyed by {slug}.{content|element}.
getContent(string $key, bool $singleQuery = false, ?int $limit = null, bool $orderById = false)getContent('features.content', true)— the single content object (passtruefor one row). Returns aFrontendmodel whose->data_valuesis an object of yourcontentfields.getContent('features.element')— the collection of repeatable rows (omittrue). Each row's->data_valuesis oneelement.
data_values is cast to an object, so you reach a field with ->data_values->heading. Because the operator may not have filled a field, always coalesce to a seed default.
Seed defaults: never render empty#
A field the operator hasn't set returns null. Every section must fall back to a sensible default so a fresh install looks finished. There are two established places to keep defaults:
- Inline in the Blade — fine for a heading or a button label:
$heading = __(@$content->data_values->heading ?? 'Why choose us?');- A JSON seed under `resources/data/{name}.json` — better for structured or repeatable defaults. The reference theme's
why_choose_sharedsection loads its tab content this way:
$configPath = resource_path('data/why-choose-shared.json');
$config = is_file($configPath) ? (json_decode(file_get_contents($configPath), true) ?: []) : [];
// Admin override layered over the JSON default:
$content = getContent('why_choose_shared.content', true);
$title = @$content->data_values->heading ?: ($config['title'] ?? 'Why Choose Us?');The pattern is always the same: admin value first, seed default second. Read Theme anatomy for where resources/data/ lives relative to your theme.
A caution carried over from the reference theme: seed defaults are published content on a real install. Don't seed invented claims (fake data-center counts, fabricated testimonials, a specific uptime number). If you have no honest default value, guard the section and render nothing — see the "guard empty" pattern below.
Failing safe: guard, and skip cleanly#
Two safety habits, both visible in the reference sections:
- Return early when there's nothing real to show.
features.blade.phpcollects its cards, drops any with no text, and if the result is empty it simplyreturn;s from the@phpblock — the section emits no markup at all rather than an empty shell. - Give repeated sections unique heading ids. The page-builder can place the same section twice on one page. A static
id="features-heading"would emit duplicate ids and breakaria-labelledby. Generate one per render:$uid = \Illuminate\Support\Str::random(6);.
Worked example: a custom "announcement" section#
Let's build a complete new section from scratch — a banner with an editable heading, subheading, and button, plus a repeatable list of highlight chips.
Step 1 — Declare it in `sections.json`. Add the slug to secs where you want it, and add the definition:
{
"secs": ["hero", "announcement", "features", "pricing"],
"announcement": {
"name": "Announcement Banner",
"builder": true,
"content": {
"heading": "text",
"subheading": "textarea",
"button_text": "text",
"button_link": "text"
},
"element": {
"label": "text"
}
}
}Step 2 — Optional seed default at `resources/data/announcement.json`:
{
"heading": "Now with one-click backups",
"subheading": "Every plan includes automatic daily backups you can restore yourself.",
"button_text": "See what's included",
"button_link": "/pricing",
"highlights": ["Daily snapshots", "One-click restore", "30-day retention"]
}Step 3 — Write `sections/announcement.blade.php`:
@php
// Load the seed default, then layer the admin edits on top.
$seedPath = resource_path('data/announcement.json');
$seed = is_file($seedPath) ? (json_decode(file_get_contents($seedPath), true) ?: []) : [];
$content = getContent('announcement.content', true); // single object (or null)
$rows = getContent('announcement.element'); // collection of rows
$heading = __(@$content->data_values->heading ?? ($seed['heading'] ?? ''));
$subheading = __(@$content->data_values->subheading ?? ($seed['subheading'] ?? ''));
$buttonText = @$content->data_values->button_text ?? ($seed['button_text'] ?? '');
$buttonLink = @$content->data_values->button_link ?? ($seed['button_link'] ?? '');
// Repeatable highlights: prefer admin rows, else the seed array. Drop blanks.
$highlights = collect();
if ($rows && count($rows) > 0) {
foreach ($rows as $row) {
$label = trim((string) (@$row->data_values->label ?? ''));
if ($label !== '') {
$highlights->push($label);
}
}
} else {
$highlights = collect($seed['highlights'] ?? [])
->map(fn ($l) => trim((string) $l))
->filter();
}
// Nothing real to show → render nothing (fail safe, never an empty shell).
if ($heading === '' && $highlights->isEmpty()) {
return;
}
// Instance-unique id: this section may be placed more than once on a page.
$uid = 'announcement-' . \Illuminate\Support\Str::random(6);
@endphp
<section aria-labelledby="{{ $uid }}" class="section-y">
<div class="container-page text-center max-w-2xl mx-auto">
@if($heading !== '')
<h2 id="{{ $uid }}" class="section-title">{{ $heading }}</h2>
@endif
@if($subheading !== '')
<p class="mt-4 text-muted">{{ $subheading }}</p>
@endif
@if($highlights->isNotEmpty())
<ul class="mt-6 flex flex-wrap justify-center gap-2">
@foreach($highlights as $label)
<li class="rounded-full px-3 py-1 text-sm ring-1 ring-inset ring-slate-900/10 dark:ring-white/10">
{{ __($label) }}
</li>
@endforeach
</ul>
@endif
@if($buttonText !== '' && $buttonLink !== '')
<div class="mt-8">
<x-theme.btn-primary :href="$buttonLink" arrow>{{ __($buttonText) }}</x-theme.btn-primary>
</div>
@endif
</div>
</section>That's the whole loop. The definition drives what the operator can edit; getContent() reads what they entered; the seed JSON fills the blanks; the guard keeps a half-filled install from looking broken; and because the slug is in secs, render-sections will pick it up automatically. The x-theme.btn-primary component keeps the button consistent with the rest of the theme — you don't have to use it, but see Components for what's available and how to override it.
The crud model (FAQ-style lists)#
Some repeatable content is better managed on its own admin screen than as inline element rows — long FAQ lists, for instance. Declaring "crud": true with an inputs schema switches the section to that managed model:
"faq": {
"name": "FAQ Section",
"builder": true,
"content": { "heading": "text", "subheading": "textarea" },
"crud": true,
"inputs": {
"question": "text",
"answer": "textarea",
"pages": "select-pages"
}
}The reference faq section reads its items through a dedicated FaqService rather than iterating getContent('faq.element') directly, and can scope items per page via the pages input. For most custom sections you won't need crud — inline element rows are simpler. Reach for crud only when the list is long, shared across pages, or needs its own admin management surface.
Checklist for every section you write#
- Slug in
secs, a matching definition, andsections/{slug}.blade.php— all three, same name. - Every editable string reads
getContent(...)first, seed default second. - No fabricated defaults; guard and
return;when there's nothing honest to show. - A unique heading id per render (
Str::random), andaria-labelledbywired to it. - Repeatable rows normalized to one clean shape, blanks dropped.
- The slug is safe to remove from
secs— the loop just skips it.
Next#
Sections build the flexible, operator-arranged pages. Next, learn how the fixed pages resolve — home, pricing, contact, cart, checkout, product, KB, and errors — in Pages and how they resolve.