Unfold CMS — Template Development Guide

Unfold CMS ships with a self-contained, file-based template system built on Laravel Blade. Unlike many CMS platforms that scatter theme logic across plugins, hooks, and database entries, every piece of a template — layouts, views, sections, options, translations, and helpers — lives inside a single directory. Drop it in, activate it, and the CMS reads the rest from your JSON config files.

Why this approach matters:

  • Zero coupling to the core — Templates never modify CMS code. You can swap, update, or delete a template without touching app/, routes/, or the database schema.
  • Admin-configurable without code changesoptions.json generates a full settings UI automatically. Site owners adjust colors, toggle features, and upload logos from the admin panel; no developer needed for day-to-day changes.
  • Safe customization via overrides — Admins can edit any template file through the built-in Template Editor. Edits are stored in a separate _templatename/ overlay directory, so the original files stay untouched and template updates never destroy customizations.
  • Portable — A template is a folder. Zip it, share it, version-control it. Moving a template between sites is a file copy.
  • Shared hosting friendly — Everything is file-based. No build step required in production, no Node.js on the server, no queue workers. The downloaded CMS package includes pre-built assets.

This guide covers everything you need to create, customize, and extend templates.


Table of Contents

  1. Architecture Overview
  2. Template Structure
  3. Configuration Files
  4. Layout System
  5. Pages & Views
  6. Sections System
  7. Components
  8. Blade Directives Reference
  9. Helper Functions
  10. Template Options
  11. Menus & Navigation
  12. Advertising Zones
  13. Localization (i18n)
  14. Dark Mode & Theming
  15. SEO & Structured Data
  16. Shortcodes
  17. Content Blocks
  18. Override System
  19. React Page Overrides
  20. Template Helpers File
  21. Creating a New Template
  22. Best Practices

Architecture Overview

Unfold CMS uses a Blade-based template engine built on top of Laravel. Templates live in resources/views/templates/ and are completely self-contained — each template includes its own layouts, pages, sections, components, configuration, translations, and helpers.

Key concepts:

  • TemplateService — resolves which template to render, manages settings and file overrides
  • SectionRegistry / SectionRepository — discovers section types on disk and reads/writes page_sections placements
  • Override System — admins can customize templates without touching originals via _templatename/ folders
  • Template Options — per-template settings defined in config/options.json, editable from the admin panel
  • Section Config — section types in sections/{id}/section.json; page allow-lists in config/pages.json

Rendering flow:

  1. A public controller calls template_view('page_name') (e.g., template_view('home'))
  2. TemplateService resolves the full view path: templates.{active_template}.page_name
  3. If an override exists at templates._{active_template}.page_name, that file is used instead
  4. Shared variables ($siteSettings, $templatePath, $currentLocale, $isRtl) are automatically injected
  5. Blade directives, helpers, and settings are available throughout the template

Template Structure

Every template lives in its own directory under resources/views/templates/:

resources/views/templates/
├── default/                    # Template directory
│   ├── template.json           # Template metadata (required)
│   ├── screenshot.png          # Preview image for admin
│   ├── helpers.php             # Template-specific PHP helpers
│   ├── layout.blade.php        # Master layout
│   ├── home.blade.php          # Homepage
│   ├── page.blade.php          # Static pages
│   ├── config/
│   │   ├── options.json        # Template options schema
│   │   └── pages.json          # Page definitions and section allow-lists
│   ├── blog/
│   │   ├── index.blade.php     # Blog listing
│   │   ├── show.blade.php      # Single post
│   │   └── category.blade.php  # Category archive
│   ├── components/
│   │   ├── header.blade.php    # Site header
│   │   ├── footer.blade.php    # Site footer
│   │   ├── post-card.blade.php # Blog post card (grid)
│   │   ├── post-card-list.blade.php # Blog post card (list)
│   │   ├── breadcrumbs.blade.php
│   │   ├── pagination.blade.php
│   │   ├── blog-sidebar.blade.php
│   │   ├── share-buttons.blade.php
│   │   ├── comment.blade.php
│   │   ├── comment-form.blade.php
│   │   └── shortcodes/
│   │       ├── contact-form.blade.php
│   │       └── contact-map.blade.php
│   ├── sections/               # One folder per section type
│   │   ├── hero/
│   │   │   ├── section.json    # Schema (fields, settings, limits)
│   │   │   ├── hero.blade.php  # Markup (receives $section)
│   │   │   └── seed.json       # Demo content for installer
│   │   ├── features/
│   │   │   ├── section.json
│   │   │   ├── features.blade.php
│   │   │   └── seed.json
│   │   └── ...                 # One folder per section type
│   └── lang/
│       ├── en.json
│       └── fa.json

Configuration Files

template.json (Required)

The main metadata file. Every template must have this file.

{
    "name": "Default Template",
    "version": "1.0.0",
    "author": "Your Name",
    "description": "A clean, modern template with card-based layouts",
    "screenshot": "screenshot.png",
    "menu_locations": {
        "header": {
            "name": "Header Navigation",
            "description": "Main navigation links in the site header"
        },
        "footer": {
            "name": "Footer Navigation",
            "description": "Footer columns — top-level items become column headers, children become links"
        }
    },
    "zones": {
        "header_leaderboard": {
            "name": "Header Leaderboard",
            "description": "Top of the page leaderboard banner",
            "size": "728x90",
            "type": "banner"
        },
        "sidebar_top": {
            "name": "Sidebar Top",
            "size": "300x250",
            "type": "sidebar"
        }
    }
}

Fields:

Field Required Description
name Yes Human-readable template name
version No Semantic version string
author No Template author name
description No Short description
screenshot No Filename of preview image (relative to template root)
menu_locations No Menu slots the template supports
zones Yes Ad zone definitions (see Advertising Zones)

config/options.json

Defines the settings UI that admins can configure for this template. These settings are rendered automatically in the admin panel under Appearance > Template Settings.

{
    "options": [
        {
            "key": "blog.posts_per_page",
            "type": "integer",
            "label": "Posts Per Page",
            "defaultValue": 12,
            "min": 4,
            "max": 48
        },
        {
            "key": "appearance.default_theme",
            "type": "radio",
            "label": "Default Theme",
            "options": [
                { "value": "system", "label": "System" },
                { "value": "light", "label": "Light" },
                { "value": "dark", "label": "Dark" }
            ],
            "defaultValue": "system",
            "col": 2
        }
    ]
}

Supported field types:

Type Description Extra Properties
text Single-line text input placeholder
textarea Multi-line text input placeholder, rows
integer Number input min, max
boolean Toggle switch children (nested fields shown when true), bordered
select Dropdown options: [{value, label}]
radio Radio buttons options: [{value, label, description}], inline
color Color picker defaultValue (hex)
url URL input placeholder
file File upload file: {accept, maxSize, preview}
slider Range slider slider: {min, max, step, unit}
divider Section heading (no key) label, action: {label, href}

Layout properties:

Property Description
col Number of columns to span (1 or 2)
bordered Whether to show border (default: true)
inline Display radio buttons inline
children Nested fields (shown when parent boolean is true)

config/pages.json

Declares which pages exist and which section types each page allows. The page builder reads this file to populate the "Add section" picker and to show the correct preview URL.

{
    "homepage": {
        "label": "Homepage",
        "description": "Sections displayed on your site's homepage",
        "url": "/",
        "allow": ["hero", "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"]
    }
}
Field Required Description
label Yes Human-readable page name shown in admin
description No Help text in the page builder
url Yes Page URL for the preview link; null for site-wide pages
allow Yes Array of section type IDs (folder names) permitted on this page

sections/{id}/section.json

Each section type has its own section.json schema inside the section folder. See Sections for the full schema reference.


Layout System

The master layout (layout.blade.php) provides the HTML skeleton. All pages extend it.

Key structure

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}"
      dir="{{ ($isRtl ?? false) ? 'rtl' : 'ltr' }}"
      x-data="themeToggle"
      data-default-theme="{{ template_option('appearance.default_theme', 'system') }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    {{-- SEO Meta Tags (auto-generated) --}}
    {!! seo() !!}

    {{-- Additional head tags from child views --}}
    @stack('head')

    {{-- Favicon --}}
    <link rel="icon" href="{{ $siteSettings['favicon'] ?? '/favicon.svg' }}">

    {{-- Fonts (configurable via settings) --}}
    <link href="https://fonts.bunny.net/css?family={{ setting_get('template.font_url', 'Inter:400,500,600,700') }}" rel="stylesheet" />

    {{-- Vite Assets --}}
    @vite(['resources/js/public.ts'])

    {{-- CSS Custom Properties --}}
    <style>
        :root {
            --color-primary: {{ setting_get('template.primary_color', '#3b82f6') }};
            --color-secondary: {{ setting_get('template.secondary_color', '#64748b') }};
            --font-family: '{{ setting_get('template.font_name', 'Inter') }}', system-ui, sans-serif;
        }
    </style>

    @stack('styles')
    @codeSnippets('head')
</head>
<body>
    @codeSnippets('body_start')

    {{-- Maintenance Banner --}}
    @maintenanceBanner

    {{-- Announcements: Top Bar --}}
    {!! \App\Services\AnnouncementService::render('top_bar') !!}

    {{-- Header --}}
    @include(($templatePath ?? 'templates.default') . '.components.header')

    {{-- Header Ad Zone --}}
    @hasAdZone('header_leaderboard')
        <div class="bg-gray-100 py-2 text-center">
            @ad_zone('header_leaderboard')
        </div>
    @endhasAdZone

    {{-- Main Content (filled by child views) --}}
    <main>
        @yield('content')
    </main>

    {{-- Footer --}}
    @include(($templatePath ?? 'templates.default') . '.components.footer')

    {{-- Announcements: Modal & Slide-in --}}
    {!! \App\Services\AnnouncementService::render('modal') !!}
    {!! \App\Services\AnnouncementService::render('slide_in') !!}

    {{-- JSON-LD Schema (pushed from child views) --}}
    @stack('schema')
    @stack('scripts')
    @codeSnippets('body_end')
</body>
</html>

Stacks available in child views

Stack Location Usage
head Inside <head> Pagination rel links, extra meta tags
styles Inside <head> Additional CSS
scripts Before </body> Additional JavaScript
schema Before </body> JSON-LD structured data

Shared variables

These are automatically available in all template views:

Variable Type Description
$siteSettings array All site settings (logo, favicon, name, etc.)
$templatePath string Current template view prefix (e.g., templates.default)
$currentLocale string Current locale (e.g., en, fa)
$isRtl bool Whether the current locale is RTL

Pages & Views

Homepage (home.blade.php)

The homepage extends the layout and renders all page sections. Sections are managed through the page builder at Admin > Pages.

@extends(($templatePath ?? 'templates.default') . '.layout')

@section('content')
{{-- Render all active placements for the homepage in sort order --}}
@renderPageSections('homepage')

{{-- Latest Posts (controller-driven, not a section placement) --}}
@if($latestPosts->isNotEmpty())
    <section>
        @foreach($latestPosts as $post)
            <article>
                <h3><a href="{{ $post->url }}">{{ $post->title }}</a></h3>
                <span>{{ $post->formatted_posted_at }}</span>
                <span>{{ $post->reading_time }} {{ __('min read') }}</span>
            </article>
        @endforeach
    </section>
@endif
@endsection

@push('schema')
@jsonld(schema_organization())
@jsonld(schema_website())
@endpush

Available data in home.blade.php:

Variable Type Description
$latestPosts Collection<Post> Latest 3 published posts
$categories Collection<Category> Published categories (up to 8)
$seoData array SEO metadata

Blog Listing (blog/index.blade.php)

Available data:

Variable Type Description
$posts LengthAwarePaginator Paginated posts
$featuredPosts Collection<Post> Featured/pinned posts
$categories Collection<Category> Category tree
$recentPosts Collection<Post> Latest 5 posts (sidebar)
$paginationMeta array ['prev' => url, 'next' => url] for rel links

Single Post (blog/show.blade.php)

Available data:

Variable Type Description
$post Post The post (with categories, author, media)
$relatedPosts Collection<Post> Posts in same categories
$comments array Threaded comments tree
$commentSettings array Comment config (max_depth, require_approval, etc.)
$isPreview bool Whether this is an admin preview

Static Page (page.blade.php)

Available data:

Variable Type Description
$page Post The page (content_type = page)

Category Archive (blog/category.blade.php)

Available data:

Variable Type Description
$category Category The current category
$posts LengthAwarePaginator Paginated posts in category
$categories Collection<Category> All categories (sidebar)
$recentPosts Collection<Post> Latest 5 posts (sidebar)

Sections System

Sections are dynamic content blocks that admins assemble on pages through the visual page builder at Admin > Pages. Each section type lives in its own folder inside the template.

How sections work

  1. Create a folder at sections/{id}/ with section.json, {id}.blade.php, and seed.json
  2. Add the section type ID to the allow list in config/pages.json for the relevant pages
  3. Admins add the section to pages through the page builder; placement data is stored in page_sections
  4. The template renders sections using @renderPageSections('page_key')

There are two types of sections:

  • Item-based: The section holds a list of items (e.g. feature cards, testimonials). Items are plain arrays in the values JSON of the placement.
  • Settings-only (settings_only: true): A single set of values — ideal for Hero, CTA, or About banners.

Writing a section Blade file

Every section Blade file receives $section — a PageSection model. Read values with $section->get('key', $default):

{{-- sections/features/features.blade.php --}}
@php
    $title = $section->get('title', 'Features');
    $items = $section->get('items', []);
@endphp

@if($title || count($items) > 0)
<section class="py-20">
    <div class="container mx-auto px-4">
        <h2>{{ $title }}</h2>

        <div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
            @foreach($items as $item)
                <div>
                    @if($item['icon'] ?? null)
                        {!! lucide_icon($item['icon'], 'h-6 w-6') !!}
                    @endif
                    <h3>{{ $item['title'] ?? '' }}</h3>
                    @if($item['body'] ?? null)
                        <p>{{ $item['body'] }}</p>
                    @endif
                    @if($item['link_url'] ?? null)
                        <a href="{{ $item['link_url'] }}">{{ __('Learn more') }}</a>
                    @endif
                </div>
            @endforeach
        </div>
    </div>
</section>
@endif

Items are plain arrays — access fields directly by key. No Eloquent model.

For settings-only sections, read all values the same way:

{{-- sections/hero/hero.blade.php --}}
@php
    $title    = $section->get('title', '');
    $subtitle = $section->get('subtitle', '');
    $imgUrl   = section_media_url('homepage', 'hero', 'illustration');
@endphp

<section class="hero">
    <h1>{{ $title }}</h1>
    <p>{{ $subtitle }}</p>
    @if($imgUrl)
        <img src="{{ $imgUrl }}" alt="">
    @endif
</section>

Rendering pages

Use @renderPageSections in the page's Blade view. It loops all active placements in sort order and includes each type's Blade file:

{{-- home.blade.php --}}
@extends(($templatePath ?? 'templates.default') . '.layout')

@section('content')
    @renderPageSections('homepage')
@endsection

For site-wide sections (header, footer) that appear in the layout, include them manually and wrap with @cmsEditWrap so they are editable in the page builder:

{{-- layout.blade.php --}}
@php $headerSection = page_section('site', 'header') @endphp

@cmsEditWrap('site', 'header')
    @include(($templatePath ?? 'templates.default') . '.sections.header.header', ['section' => $headerSection])
@endCmsEditWrap

Adding a section to other pages

To support a new page (e.g. about):

  1. Add the page to config/pages.json:

    "about": {
        "label": "About Page",
        "description": "Sections displayed on the about page",
        "url": "/about",
        "allow": ["about_hero", "about_team", "about_cta"]
    }
    
  2. Create section folders for each type in allow

  3. In the about page Blade view:

    @renderPageSections('about')
    

The page builder automatically shows the page and its allowed section types.


Components

Components are reusable Blade partials included via @include. Place them in components/.

Including components

{{-- Include with template path for override support --}}
@include(($templatePath ?? 'templates.default') . '.components.header')

{{-- Include with direct path (no override support) --}}
@include('templates.default.components.post-card', ['post' => $post])

{{-- Include with data --}}
@include('templates.default.components.breadcrumbs', [
    'items' => [
        ['label' => __('Blog'), 'url' => route('blog.index')],
        ['label' => $post->title],
    ]
])

Key components in the default template

Component Description Expected Props
header Site header with navigation, search, theme toggle, auth
footer Site footer with menu columns, social links, copyright
post-card Blog post card for grid layout $post
post-card-list Blog post card for list layout $post
breadcrumbs Breadcrumb navigation $items (array of [label, url?])
pagination Pagination links Passed via $posts->links(...)
blog-sidebar Blog sidebar (categories, recent posts, newsletter)
share-buttons Social share buttons on posts $post
comment Single comment with nesting $comment, $depth, $maxDepth
comment-form Comment submission form $post
shortcodes/contact-form Contact form shortcode
shortcodes/contact-map OpenStreetMap embed shortcode $lat, $lng, $zoom, $height

Blade Directives Reference

Unfold CMS provides 30+ custom Blade directives. These are available in all template files.

Site Identity

Directive Output Example
@siteName Site name from settings <span>@siteName</span>
@siteTagline Site tagline <p>@siteTagline</p>
@siteLogo('classes') Logo <img> with classes, or site name text if no logo @siteLogo('h-8 w-auto')
@siteLogoUrl Just the logo URL <img src="@siteLogoUrl" alt="Logo">
@siteFavicon Favicon URL <link rel="icon" href="@siteFavicon">
@supportEmail Support email address <a href="mailto:@supportEmail">Contact</a>
@copyright Full copyright line with year and site name <footer>@copyright</footer>

Content & Blocks

Directive Description Example
@block('slug') Render a content block by slug @block('homepage-hero')
@hasBlock('slug') ... @endhasBlock Conditional: check if block exists @hasBlock('sidebar') <div>@block('sidebar')</div> @endhasBlock

Content blocks support conditional rendering:

  • @auth ... @endauth — Show only to logged-in users
  • @guest ... @endguest — Show only to guests
  • @admin ... @endadmin — Show only to admins
  • @role('role') ... @endrole — Show only to specific roles

Content blocks support user interpolation: {{ user.name }}, {{ user.email }}, {{ user.first_name }}, etc.

Directive Description Example
@menu('slug') Render a menu by slug @menu('main-navigation')
@menu(id) Render a menu by ID @menu(1)

Advertising

Directive Description Example
@ad_zone('key') Render an ad zone @ad_zone('sidebar-banner')
@hasAdZone('key') ... @endhasAdZone Conditional: check if ad zone is active @hasAdZone('header') @ad_zone('header') @endhasAdZone

Settings & Template Options

Directive Description Example
@setting('key', 'default') Output site setting (escaped) @setting('app.tagline', 'Welcome')
@settingRaw('key', 'default') Output site setting (unescaped, for HTML) @settingRaw('seo.meta_scripts')
@hasSetting('key') ... @endhasSetting Conditional: setting exists and is truthy @hasSetting('app.logo') ... @endhasSetting
@option('key') Output template option (escaped) @option('footer.copyright_text')
@optionRaw('key') Output template option (unescaped) @optionRaw('footer.custom_html')
@hasOption('key') ... @endhasOption Conditional: template option is truthy @hasOption('sidebar.enabled') ... @endhasOption
@templateOption('key', 'default') Template option with default (escaped) @templateOption('sidebar.enabled', true)
@templateOptionRaw('key', 'default') Template option with default (unescaped) @templateOptionRaw('custom.css')

Sections

Directive Description Example
@renderPageSections('page') Render all active placements for a page in order @renderPageSections('homepage')
@cmsEditWrap('location', 'type') ... @endCmsEditWrap Wrap a manually-included section with page-builder edit attributes @cmsEditWrap('site', 'footer')
@cmsEditBridge Emit the page-builder bridge JS/CSS (once, near </body>) @cmsEditBridge
@sectionEnabled('location') Legacy: check if a section is enabled (old system) @sectionEnabled('homepage.features')
@hasSectionItems('location') Legacy: check if a section has published items (old system) @hasSectionItems('homepage.faq')

Shortcodes

Directive Description Example
@shortcodes($content) Process shortcodes in content @shortcodes($page->body)

Authentication & Authorization

Directive Description Example
@authenticated ... @endauthenticated Content for logged-in users @authenticated <a href="/dashboard">Dashboard</a> @endauthenticated
@authId Current user ID <input type="hidden" value="@authId">
@admin ... @endadmin Content for admin users @admin <a href="/admin">Admin</a> @endadmin
@superAdmin ... @endsuperAdmin Content for super admins @superAdmin <a href="/admin/settings">Settings</a> @endsuperAdmin
@hasRole('role') ... @endhasRole Content for specific role @hasRole('editor') <button>Edit</button> @endhasRole
@hasAnyRole(['r1', 'r2']) ... @endhasAnyRole Content for any of roles @hasAnyRole(['admin', 'editor']) ... @endhasAnyRole
@hasAllRoles(['r1', 'r2']) ... @endhasAllRoles Content for all roles @hasAllRoles(['admin', 'mod']) ... @endhasAllRoles
@canUser('perm') ... @endcanUser Content for specific permission @canUser('edit-posts') ... @endcanUser

Marketing & Announcements

Directive Description Example
@announcement('position') Render announcement for position @announcement('top_bar')
@announcements Render all active announcements @announcements
@codeSnippets('position') Custom code for position (head, body_start, body_end) @codeSnippets('head')
@maintenanceBanner Maintenance mode banner @maintenanceBanner

Environment

Directive Description Example
@localhost ... @endlocalhost Content only on localhost @localhost <div>Debug</div> @endlocalhost
@production ... @endproduction Content only in production @production <!-- Analytics --> @endproduction

Action Context

Directive Description Example
@editing ... @endediting Content when editing @editing <button>Update</button> @endediting
@creating ... @endcreating Content when creating @creating <button>Create</button> @endcreating

Helper Functions

These PHP helpers are available globally in all template files.

Template & View helpers

// Resolve the full view path for the active template
template_view('home');        // → 'templates.default.home'
template_view('blog.index');  // → 'templates.default.blog.index'

// Get a template option value from the active template's options.json
template_option('blog.posts_per_page', 12);
template_option('sidebar.enabled', true);

Section helpers

// Get all active placements for a page (ordered)
$sections = page_sections('homepage');  // → PageSection[]

// Get one placement by type
$footer = page_section('site', 'footer');  // → PageSection|null
$tagline = $footer?->get('tagline', '');

// Read one value from a placement
$title = section_value('homepage', 'hero', 'title', 'Welcome');

// Resolve a media/image field to a URL
$logo = section_media_url('site', 'footer', 'logo_light');  // → string|null

Icon helpers

// Render a Lucide icon as inline SVG
{!! lucide_icon('shield', 'h-6 w-6') !!}
{!! lucide_icon('arrow-right', 'h-4 w-4 text-brand') !!}

Settings helpers

// Get a site setting
setting_get('app.name', 'My Site');
setting_get('social.twitter');

// Check if a setting exists
setting_has('comments.enabled');

// Set a setting (use sparingly in templates)
setting_set('key', 'value', 'string');
// Get menu items for a location (cached collection)
$headerItems = menu_items('header');
$footerItems = menu_items('footer');

// Each item has: ->label, ->resolved_url, ->target, ->allChildren
@foreach(menu_items('header') as $item)
    <a href="{{ $item->resolved_url }}" target="{{ $item->target }}">
        {{ $item->label }}
    </a>
    @foreach($item->allChildren as $child)
        <a href="{{ $child->resolved_url }}">{{ $child->label }}</a>
    @endforeach
@endforeach

// Render a full menu by slug or ID (uses the @menu Blade directive)
@menu('footer-navigation')
@menu('main-navigation')

Auth helpers

authCheck();   // Is authenticated?
authUser();    // Get current user
authId();      // Get current user ID
isAdmin();     // Has admin role?
isSuperAdmin(); // Has super_admin role?
hasRole('editor');
canUser('edit-posts');

Formatting helpers

// Locale-aware date formatting (uses Jalali for Persian locale)
format_date($date, 'Y/m/d');
format_datetime($date, 'Y/m/d', 'H:i');
format_duration(3600, short: true); // "1h"

// Format bytes as human-readable
format_bytes(1048576); // "1 MB"

// Truncate text with word boundary
blurb($text, 150); // "First 150 chars..."

JSON-LD Schema helpers

// Organization schema (for homepage)
schema_organization();

// Website schema with SearchAction
schema_website();

// Article schema for blog posts
schema_article($post);

// WebPage schema for static pages
schema_webpage($page);

// Breadcrumb schema
schema_breadcrumb_list([
    ['name' => 'Home', 'url' => url('/')],
    ['name' => 'Blog', 'url' => route('blog.index')],
    ['name' => $post->title, 'url' => url()->current()],
]);

Environment helpers

is_localhost();    // Running on localhost?
is_production();   // Running in production?
is_demo_mode();    // Demo mode enabled?

Template Options

Template options are the primary way for admins to customize your template's appearance and behavior.

How it works

  1. Define options in config/options.json
  2. Values are stored in the database as template.{name}.{key}
  3. Read values in templates with template_option() or the @option directive
  4. Options are automatically shown in Admin > Appearance > Template Settings

Reading options in templates

{{-- Using the @option directive (escaped) --}}
<h1>@option('footer.copyright_text')</h1>

{{-- Using @hasOption for conditional rendering --}}
@hasOption('sidebar.enabled')
    @include('templates.default.components.blog-sidebar')
@endhasOption

{{-- Using the PHP helper (for logic) --}}
@php
    $postsPerPage = template_option('blog.posts_per_page', 12);
    $sidebarEnabled = template_option('sidebar.enabled', true);
@endphp

{{-- Combining conditionals --}}
@if(template_option('sidebar.enabled', true) && template_option('sidebar.position', 'right') === 'left')
    @include('templates.default.components.blog-sidebar')
@endif

Menus are managed by admins and assigned to template "locations" defined in template.json.

Defining menu locations

In template.json:

{
    "menu_locations": {
        "header": {
            "name": "Header Navigation",
            "description": "Main navigation links"
        },
        "footer": {
            "name": "Footer Navigation",
            "description": "Footer link columns"
        }
    }
}

Rendering menus

{{-- Simple menu rendering --}}
@foreach(menu_items('header') as $item)
    @if($item->allChildren->count() > 0)
        {{-- Item with dropdown --}}
        <div x-data="{ open: false }">
            <button @click="open = !open">{{ $item->label }}</button>
            <div x-show="open">
                @foreach($item->allChildren as $child)
                    <a href="{{ $child->resolved_url }}" target="{{ $child->target }}">
                        {{ $child->label }}
                    </a>
                @endforeach
            </div>
        </div>
    @else
        <a href="{{ $item->resolved_url }}" target="{{ $item->target }}">
            {{ $item->label }}
        </a>
    @endif
@endforeach

The default template uses a pattern where top-level menu items become column headings and their children become the links:

@php
    $footerItems = menu_items('footer');
    $columns = $footerItems->filter(fn($item) => $item->allChildren->isNotEmpty());
    $standalone = $footerItems->filter(fn($item) => $item->allChildren->isEmpty());
@endphp

@foreach($columns as $column)
    <div>
        <h3>{{ $column->label }}</h3>
        <ul>
            @foreach($column->allChildren as $link)
                <li><a href="{{ $link->resolved_url }}">{{ $link->label }}</a></li>
            @endforeach
        </ul>
    </div>
@endforeach
Property Type Description
->label string Display text
->resolved_url string The URL (resolved from type: page/post/custom)
->target string Link target (_self, _blank)
->allChildren Collection<MenuItem> Child items
->is_active bool Whether the item is active
->sort_order int Sort position

Advertising Zones

Ad zones are defined in template.json and rendered with Blade directives.

Defining zones

{
    "zones": {
        "header_leaderboard": {
            "name": "Header Leaderboard",
            "description": "Banner at the top of every page",
            "size": "728x90",
            "type": "banner"
        },
        "sidebar_top": {
            "name": "Sidebar Top",
            "size": "300x250",
            "type": "sidebar"
        },
        "article_inline": {
            "name": "Article Inline",
            "description": "Inside blog posts, below content",
            "size": "728x90",
            "type": "banner"
        },
        "popup_overlay": {
            "name": "Popup Overlay",
            "size": "custom",
            "type": "popup"
        }
    }
}

Zone types: banner, sidebar, popup

Rendering zones in templates

Always wrap ad zones in @hasAdZone to avoid rendering empty containers:

@hasAdZone('sidebar_top')
    <div class="my-4">
        @ad_zone('sidebar_top')
    </div>
@endhasAdZone

Zones are automatically synced to the database when the admin interface loads. Admins can then create ads and assign them to zones.


Localization (i18n)

Templates support full localization via Laravel's __() translation helper and per-template JSON language files.

Translation files

Place language files in lang/:

templates/default/lang/
├── en.json
└── fa.json

Format

{
    "Featured Posts": "Featured Posts",
    "Latest Posts": "Latest Posts",
    "min read": "min read",
    "No posts yet": "No posts yet",
    "Search": "Search",
    "Login": "Login",
    "Sign Up": "Sign Up"
}

Using translations

{{-- Simple translation --}}
<h2>{{ __('Latest Posts') }}</h2>

{{-- With parameters --}}
<span>{{ $post->reading_time }} {{ __('min read') }}</span>

{{-- Pluralization --}}
{{ trans_choice(':count Comment|:count Comments', count($comments), ['count' => count($comments)]) }}

RTL support

The layout automatically sets dir="rtl" based on the active locale. Use logical CSS properties for RTL compatibility:

{{-- Use start/end instead of left/right --}}
<div class="ms-2 me-4 ps-3 pe-5 text-start">

{{-- RTL-aware rotations --}}
<span class="rtl:rotate-180">&rarr;</span>

{{-- RTL-aware transforms --}}
<span class="group-hover:translate-x-1 rtl:group-hover:-translate-x-1">&rarr;</span>

Dark Mode & Theming

CSS custom properties

The template system uses CSS custom properties for theming, set in the layout:

:root {
    --color-primary: #3b82f6;   /* Configurable via admin */
    --color-secondary: #64748b; /* Configurable via admin */
    --font-family: 'Inter', system-ui, sans-serif;
}

Brand color utilities

The layout defines utility classes based on --color-primary:

Class Description
.text-brand Primary color text
.bg-brand Primary color background
.bg-brand-light 10% primary on white
.bg-brand-dark 90% primary + 10% black
.border-brand Primary color border
.hover:text-brand Hover primary text
.hover:bg-brand Hover primary background

Dark mode

Uses Tailwind's dark: variant with a class-based strategy. The theme is toggled via Alpine.js:

{{-- Light/dark text --}}
<h1 class="text-gray-900 dark:text-white">Title</h1>

{{-- Light/dark backgrounds --}}
<section class="bg-white dark:bg-gray-800">

{{-- Light/dark borders --}}
<div class="border-gray-200 dark:border-gray-700">

The default theme mode is configurable via appearance.default_theme option (system, light, or dark).


SEO & Structured Data

Meta tags

SEO meta tags are automatically generated via the ralphjsmit/laravel-seo package. The layout includes:

{!! seo() !!}

Controllers set SEO data before rendering:

// In controllers
seo()->for($post);  // Auto-generates title, description, OG tags

JSON-LD schemas

Use the @push('schema') stack to add structured data:

{{-- Homepage --}}
@push('schema')
<script type="application/ld+json">
{!! json_encode(schema_organization(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
<script type="application/ld+json">
{!! json_encode(schema_website(), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
@endpush

{{-- Blog post --}}
@push('schema')
<script type="application/ld+json">
{!! json_encode(schema_article($post), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
@endpush

{{-- Static page --}}
@push('schema')
<script type="application/ld+json">
{!! json_encode(schema_webpage($page), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
<script type="application/ld+json">
{!! json_encode(schema_breadcrumb_list([
    ['name' => __('Home'), 'url' => setting_get('app.url')],
    ['name' => $page->title, 'url' => url()->current()],
]), JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) !!}
</script>
@endpush

Pagination SEO

Blog listing pages push rel="prev" and rel="next" links:

@push('head')
@if(isset($paginationMeta))
    @if($paginationMeta['prev'])
        <link rel="prev" href="{{ $paginationMeta['prev'] }}">
    @endif
    @if($paginationMeta['next'])
        <link rel="next" href="{{ $paginationMeta['next'] }}">
    @endif
@endif
@endpush

Shortcodes

Shortcodes allow admins to embed interactive components within page content.

Built-in shortcodes

Shortcode Description Parameters
[contact-form] Contact form with file upload title
[contact-map] OpenStreetMap embed lat, lng, zoom, height

Using shortcodes

In page body content (written by admins in the editor):

[contact-form title="Get in Touch"]

[contact-map lat="35.6892" lng="51.3890" zoom="14" height="400"]

Rendering shortcodes in templates

Use the @shortcodes directive to process page content:

<div class="prose">
    @shortcodes($page->body)
</div>

Creating a custom shortcode

Shortcodes are registered in SettingsServiceProvider. Each shortcode has:

  • A regex pattern to match in content
  • A Blade partial to render the output
  • A partial located in components/shortcodes/

Content Blocks

Content blocks are reusable content snippets managed from the admin panel and rendered using the @block directive.

{{-- Render a block by slug --}}
@block('homepage-banner')

{{-- Check if a block exists before rendering --}}
@hasBlock('sidebar-widget')
    <div class="widget">
        @block('sidebar-widget')
    </div>
@endhasBlock

Blocks support conditional rendering and user field interpolation (see Blade Directives).


Override System

The override system lets admins customize template files without modifying the originals. This is crucial for surviving template updates.

How it works

  • Original files: templates/default/file.blade.php
  • Override files: templates/_default/file.blade.php (underscore prefix)
  • When rendering, the override is checked first, then the original

Template editor

Admins can edit template files through the built-in Template Editor at /admin/template-editor. The editor:

  1. Shows a file tree of all editable files
  2. Highlights overridden files with a badge
  3. Saves changes to the override directory (_default/)
  4. Allows reverting overrides to restore the original
  5. Shows a diff view comparing original vs. override
  6. Lists all available Blade directives as a reference sidebar

Warning: Incorrect changes to Blade templates can break your site's frontend. Always verify changes after saving, and use the revert feature if something goes wrong.

Editable file types

blade.php, php, json, css, js, html, md, txt, xml

Override resolution in code

// TemplateService resolves views with override support
$view = template_view('home');
// If _default/home.blade.php exists → 'templates._default.home'
// Otherwise → 'templates.default.home'

Template helpers override

The helpers.php file also supports overrides:

  1. Override helpers at _default/helpers.php load first
  2. Original helpers at default/helpers.php load second
  3. Because functions use function_exists() guards, override functions take precedence

React Page Overrides

Templates can provide custom React/Inertia pages that override core CMS pages (e.g., auth pages). This makes templates fully portable — when you copy a template to a new CMS installation, custom page designs come with it.

How it works

The system has two parts: a backend PageResolver and a frontend page resolver.

BackendPageResolver checks if the active template provides a .tsx file for the requested page. If found, it returns the template's page path; otherwise it falls back to the core page.

// app/Services/PageResolver.php
$pageResolver = app(PageResolver::class);
$pageResolver->resolve('auth/login/index');
// → 'templates/{template}/pages/auth/login/index' (if override exists)
// → 'auth/login/index' (fallback to core)

Frontendapp.tsx and ssr.tsx use import.meta.glob to collect both core and template pages, then the shared resolve-page.ts utility picks the right component:

// app.tsx / ssr.tsx
const corePages = import.meta.glob('./pages/**/*.tsx');
const templatePages = import.meta.glob('./templates/*/pages/**/*.tsx');

resolve: (name) => resolvePageComponent(name, corePages, templatePages),

Detection is automatic

No configuration needed. PageResolver uses File::exists() to check for template page files at runtime. If the file exists, it's used. If not, the core page renders.

Directory structure

Template pages live under resources/js/templates/{name}/pages/, mirroring the core pages/ structure:

resources/js/templates/{template}/
├── layouts/
│   └── auth/
│       ├── auth-layout.tsx         # Layout dispatcher
│       ├── auth-split-layout.tsx   # Split layout variant
│       ├── auth-card-layout.tsx    # Card layout variant
│       └── auth-simple-layout.tsx  # Simple layout variant
└── pages/
    └── auth/
        ├── login/index.tsx
        ├── sign-up/index.tsx
        ├── forgot-password/index.tsx
        ├── reset-password/index.tsx
        ├── verify-email/index.tsx
        ├── confirm-password/index.tsx
        ├── two-factor-challenge/index.tsx
        └── otp/index.tsx

Creating a page override

  1. Create the file at resources/js/templates/{template}/pages/{page-path}.tsx (or {page-path}/index.tsx)
  2. The component receives the same Inertia props as the core page
  3. Import core UI components via @/components/ui/* — they work everywhere
  4. Import template-specific layouts via relative paths (not @/)
// resources/js/templates/{template}/pages/auth/login/index.tsx
import AuthLayout from '../../../layouts/auth/auth-layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';

export default function Login({ status, canResetPassword }: { ... }) {
    return (
        <AuthLayout title="Sign in" description="Welcome back">
            {/* Your custom login form */}
        </AuthLayout>
    );
}

Shared Inertia prop

The active template name is shared as an Inertia prop via HandleInertiaRequests:

// Available on every page as usePage().props.activeTemplate
'activeTemplate' => app(TemplateService::class)->getActiveTemplate(),

What can be overridden

Any page that uses PageResolver::resolve() can be overridden. Currently this includes all public auth pages:

  • auth/login/index
  • auth/sign-up/index
  • auth/forgot-password/index
  • auth/reset-password/index
  • auth/verify-email/index
  • auth/confirm-password/index
  • auth/two-factor-challenge/index
  • auth/otp/index

Admin pages (admin/auth/*) are not overridable — they always use core pages.

Extending to other pages

To make any other page overridable, use PageResolver in the controller:

$pageResolver = app(PageResolver::class);
return Inertia::render($pageResolver->resolve('some/page/index'), $data);

Template Helpers File

Each template can include a helpers.php file with custom PHP functions used across its views.

Structure

<?php
// templates/default/helpers.php

if (!function_exists('hero_overlay_style')) {
    function hero_overlay_style(): string
    {
        $color = section_value('homepage', 'hero', 'overlay_color', '#000000');
        // ... build CSS style string
        return "background: rgba(...)";
    }
}

if (!function_exists('footer_social_links')) {
    function footer_social_links(): array
    {
        return array_filter([
            'twitter' => template_option('footer.social.twitter', true)
                ? setting_get('social.twitter') : null,
            // ...
        ]);
    }
}

Rules

  • Always wrap functions in if (!function_exists('...')) guards
  • This enables the override system — override helpers define functions first
  • Keep functions focused and template-specific
  • Use template_option() and setting_get() for configuration values

Creating a New Template

Step-by-step

  1. Create the directory structure:
resources/views/templates/my-template/
├── template.json           # Required
├── screenshot.png          # Recommended
├── helpers.php             # Optional
├── layout.blade.php        # Required
├── home.blade.php          # Required
├── page.blade.php          # Required
├── config/
│   ├── options.json        # Recommended
│   └── pages.json          # Optional (if you want page sections)
├── blog/
│   ├── index.blade.php     # Required
│   ├── show.blade.php      # Required
│   └── category.blade.php  # Required
├── components/
│   ├── header.blade.php
│   ├── footer.blade.php
│   └── ...
├── sections/               # One folder per section type
│   ├── hero/
│   │   ├── section.json
│   │   ├── hero.blade.php
│   │   └── seed.json
│   └── ...
└── lang/
    └── en.json             # Required
  1. Create template.json with metadata, menu locations, and ad zones

  2. Create layout.blade.php with the HTML skeleton, including:

    • {!! seo() !!} for SEO meta tags
    • @vite(...) for assets
    • @yield('content') for page content
    • @stack('head'), @stack('styles'), @stack('scripts'), @stack('schema')
    • @codeSnippets('head'), @codeSnippets('body_start'), @codeSnippets('body_end')
    • @cmsEditBridge near </body> (enables page-builder editing)
  3. Create page views (home.blade.php, page.blade.php, blog/*.blade.php) that extend the layout. Use @renderPageSections('page_key') in home.blade.php.

  4. Define options in config/options.json for admin customization

  5. Define pages in config/pages.json and create matching section folders

  6. Validate your section schemas: php artisan sections:check my-template

  7. Add translations in lang/en.json (and other languages)

  8. Add seed data via seed.json in each section folder; SectionInstaller reads them during template installation

  9. Activate the template from Admin > Appearance > Templates

Minimal template

At minimum, a template needs:

my-template/
├── template.json
├── layout.blade.php
├── home.blade.php
├── page.blade.php
├── blog/
│   ├── index.blade.php
│   ├── show.blade.php
│   └── category.blade.php
└── components/
    ├── header.blade.php
    └── footer.blade.php

Best Practices

Performance

  • Use page_sections() / page_section() / section_value() for section data — results are cached via SectionRepository
  • Use section_media_url() for image fields — handles both media library IDs and raw URL seeds
  • Use menu_items() for menus — it's cached automatically
  • Use template_option() for settings — it's cached per-request
  • Avoid database queries directly in templates; rely on data passed from controllers

Compatibility

  • Use ($templatePath ?? 'templates.default') when including the layout or shared components to support the override system
  • Use {{ __('text') }} for all user-facing strings
  • Use logical CSS properties (ms-, me-, ps-, pe-, start, end) for RTL support
  • Use rtl: variants for RTL-specific transforms

Styling

  • Use Tailwind CSS with dark: variants for dark mode support
  • Use var(--color-primary) and the .bg-brand, .text-brand utilities for brand colors
  • Use color-mix(in srgb, var(--color-primary) 10%, white) for computed brand shades
  • Keep sections self-contained — each section should work independently

Structure

  • Keep blade templates clean — extract complex logic into helpers.php
  • Use @include for reusable components
  • Use @push/@stack for page-specific head/script content
  • Always check for empty data before rendering: @if($items->isNotEmpty())

SEO

  • Every page should push JSON-LD schema to the schema stack
  • Use {!! seo() !!} in the layout (handled automatically)
  • Add rel="prev" / rel="next" for paginated pages
  • Use semantic HTML (<article>, <section>, <nav>, <header>, <footer>)

Security

  • Always escape output with {{ }} (double curly braces)
  • Only use {!! !!} for trusted HTML (from the CMS itself, like post body)
  • Use @csrf in all forms
  • Never output raw user input without sanitization