Skip to content

Building sections

The full section system: sections.json definitions, the render pipeline, admin-editable content, repeatable elements, and a complete worked example.

9 min readUpdated Aug 13, 2026
On this page

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:

PartLives inPurpose
Definitionsections.json (a key per section)Tells the admin page-builder what fields this section exposes to edit
Viewsections/{slug}.blade.phpRenders 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.

json
{
  "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:

json
"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:

FieldTypeWhat it does
namestringHuman label shown in the admin builder.
builderbooltrue = editable in the page-builder.
contentobjectThe section's single editable fields, as {field: type}. Types: text, textarea, image, select-pages.
elementobjectSchema for a repeatable row (the features grid, testimonial cards). Same {field: type} shape. Produces many rows under {slug}.element.
crud + inputsbool + objectAlternative repeatable model with its own managed CRUD screen; inputs is the row schema. Used by faq.
no_selectionboolMarks 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:

blade
@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:

blade
@foreach($renderSecs as $sec)
    @php $secView = templateViewName('sections.' . $sec); @endphp
    @if(view()->exists($secView))
        @include($secView)
    @endif
@endforeach

Three 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 in secs never 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() and templateViewName() are the deprecated aliases of activeTheme() and themeViewName(). New code should prefer the theme* names — see Helper & template reference. The reference theme still uses the template* 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}.

php
getContent(string $key, bool $singleQuery = false, ?int $limit = null, bool $orderById = false)
  • getContent('features.content', true) — the single content object (pass true for one row). Returns a Frontend model whose ->data_values is an object of your content fields.
  • getContent('features.element') — the collection of repeatable rows (omit true). Each row's ->data_values is one element.

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:

  1. Inline in the Blade — fine for a heading or a button label:
php
   $heading = __(@$content->data_values->heading ?? 'Why choose us?');
  1. A JSON seed under `resources/data/{name}.json` — better for structured or repeatable defaults. The reference theme's why_choose_shared section loads its tab content this way:
php
   $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.php collects its cards, drops any with no text, and if the result is empty it simply return;s from the @php block — 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 break aria-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:

json
{
  "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`:

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`:

blade
@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:

json
"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, and sections/{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), and aria-labelledby wired 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.

sectionspage-builderbladecontent
Was this article helpful?
Still stuck?Contact support
Building sections · Salieno Docs