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 changes —
options.jsongenerates 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
- Architecture Overview
- Template Structure
- Configuration Files
- Layout System
- Pages & Views
- Sections System
- Components
- Blade Directives Reference
- Helper Functions
- Template Options
- Menus & Navigation
- Advertising Zones
- Localization (i18n)
- Dark Mode & Theming
- SEO & Structured Data
- Shortcodes
- Content Blocks
- Override System
- React Page Overrides
- Template Helpers File
- Creating a New Template
- 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
- SectionService — manages dynamic homepage sections (CRUD, caching, ordering)
- 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 — page sections and page labels defined in
config/sections.json
Rendering flow:
- A public controller calls
template_view('page_name')(e.g.,template_view('home')) TemplateServiceresolves the full view path:templates.{active_template}.page_name- If an override exists at
templates._{active_template}.page_name, that file is used instead - Shared variables (
$siteSettings,$templatePath,$currentLocale,$isRtl) are automatically injected - 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
│ │ └── sections.json # Section definitions
│ ├── 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/
│ │ ├── about.blade.php
│ │ ├── contact-faq.blade.php
│ │ ├── contact-info.blade.php
│ │ ├── contact-map.blade.php
│ │ ├── cta.blade.php
│ │ ├── faq.blade.php
│ │ ├── features.blade.php
│ │ ├── hero.blade.php
│ │ ├── integrations.blade.php
│ │ ├── stats.blade.php
│ │ ├── tabs.blade.php
│ │ └── testimonials.blade.php
│ └── 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/sections.json
Defines the page sections that admins can populate with content items. The file has two top-level keys: pages (page labels for the admin UI) and sections (the section definitions).
{
"pages": {
"homepage": {
"label": "Homepage",
"description": "Sections displayed on your site's homepage"
}
},
"sections": {
"homepage.stats": {
"name": "Stats",
"description": "Key numbers and statistics",
"block_type": "stat",
"max": 6,
"page": "homepage",
"title_label": "Value",
"body_label": "Label",
"supports_featured_image": false,
"fields": []
},
"homepage.features": {
"name": "Features",
"description": "Feature highlights on the homepage",
"block_type": "feature",
"max": 12,
"page": "homepage",
"title_label": "Heading",
"body_label": "Description",
"supports_featured_image": false,
"fields": [
{
"key": "icon",
"type": "icon_picker",
"label": "Icon",
"placeholder": "Search icons..."
},
{
"key": "link_url",
"type": "url",
"label": "Link URL",
"placeholder": "/features/security"
}
]
},
"homepage.testimonials": {
"name": "Testimonials",
"description": "Customer testimonials",
"block_type": "testimonial",
"max": 12,
"page": "homepage",
"title_label": "Quote",
"body_label": "Full Testimonial",
"supports_featured_image": true,
"fields": [
{
"key": "author_name",
"type": "text",
"label": "Author Name",
"validation": { "required": true }
},
{
"key": "author_role",
"type": "text",
"label": "Author Role"
},
{
"key": "rating",
"type": "select",
"label": "Rating",
"options": [
{ "value": "5", "label": "5 Stars" },
{ "value": "4", "label": "4 Stars" }
]
}
]
}
}
}
Page label fields (pages key):
| Field | Required | Description |
|---|---|---|
label |
Yes | Human-readable page name shown in admin |
description |
No | Help text shown below the page heading in admin |
Page labels live in the template config so templates are fully portable — copy the folder and the admin UI displays correct page headings automatically.
Section config fields (sections key):
| Field | Required | Description |
|---|---|---|
name |
Yes | Display name in admin |
description |
No | Help text for admins |
block_type |
Yes | Internal block type identifier |
max |
Yes | Maximum number of items allowed |
page |
Yes | Which page this section belongs to (must match a key in pages) |
title_label |
No | Custom label for the title field (default: "Title") |
body_label |
No | Custom label for the body field (default: "Body") |
supports_featured_image |
No | Whether items can have images (default: false) |
fields |
Yes | Array of extra fields (stored in extra_attributes) |
Section field types:
| Type | Description |
|---|---|
text |
Text input |
url |
URL input |
color |
Color picker |
icon_picker |
Lucide icon search/select |
select |
Dropdown with options |
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 enabled sections. The hero section is a settings-only section managed under Appearance > Sections.
@extends(($templatePath ?? 'templates.default') . '.layout')
@section('content')
{{-- Hero Section (settings-only section) --}}
@sectionEnabled('homepage.hero')
@include('templates.default.sections.hero')
@endsectionEnabled
{{-- Template Sections --}}
@sectionEnabled('homepage.features')
@include('templates.default.sections.features')
@endsectionEnabled
@sectionEnabled('homepage.testimonials')
@include('templates.default.sections.testimonials')
@endsectionEnabled
{{-- ... more sections ... --}}
{{-- Latest Posts --}}
@sectionEnabled('homepage.latest_posts')
@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
@endsectionEnabled
@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 can populate, reorder, enable/disable, and customize from the admin panel. While the default template uses sections on the homepage, the system supports sections on any page.
How sections work
- You define sections in
config/sections.json(location key, fields, limits) - You create a Blade file in
sections/that renders the content - Admins manage content through the admin panel (Appearance > Sections)
There are two types of sections:
- Item-based: Items are stored as
Postrecords withcontent_type = block. Extra fields use theextra_attributesJSON column. - Settings-only: Data is stored as key-value settings. Ideal for single-instance content like Hero, About, or CTA banners.
Writing a section template
Item-based sections
Item-based sections loop through a collection of items:
{{-- sections/features.blade.php --}}
@php $items = section_items('homepage.features'); @endphp
@if($items->isNotEmpty())
<section class="py-20">
<div class="container mx-auto px-4">
<h2>{{ __('Features') }}</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
@foreach($items as $item)
<div>
{{-- Icon (from extra_attributes) --}}
@if($item->extra_attributes['icon'] ?? null)
{!! lucide_icon($item->extra_attributes['icon'], 'h-6 w-6') !!}
@endif
{{-- Title --}}
<h3>{{ $item->title }}</h3>
{{-- Body --}}
@if($item->body)
<p>{{ $item->body }}</p>
@endif
{{-- Custom field: link --}}
@if($item->extra_attributes['link_url'] ?? null)
<a href="{{ $item->extra_attributes['link_url'] }}">
{{ __('Learn more') }}
</a>
@endif
</div>
@endforeach
</div>
</div>
</section>
@endif
Settings-only sections
Settings-only sections read key-value settings instead of items:
{{-- sections/about.blade.php --}}
<section class="py-20">
<div class="container mx-auto px-4">
<h2>{{ section_setting('homepage.about', 'heading') }}</h2>
<p>{{ section_setting('homepage.about', 'subtitle') }}</p>
<p>{{ section_setting('homepage.about', 'description') }}</p>
@if(section_setting('homepage.about', 'button_text'))
<a href="{{ section_setting('homepage.about', 'button_url', '#') }}">
{{ section_setting('homepage.about', 'button_text') }}
</a>
@endif
</div>
</section>
Section item properties
Each $item is a Post model with:
| Property | Type | Description |
|---|---|---|
$item->title |
string |
The item's title |
$item->body |
string |
The item's body/description |
$item->short_description |
string|null |
Optional short description |
$item->extra_attributes |
array |
Custom fields defined in sections.json |
$item->is_published |
bool |
Publication status |
$item->getFirstMediaUrl('featured-image') |
string |
Featured image URL |
$item->getFirstMediaUrl('featured-image', 'thumbnail') |
string |
Thumbnail variant |
$item->getFirstMediaUrl('featured-image', 'medium') |
string |
Medium variant |
$item->getFirstMediaUrl('featured-image', 'large') |
string |
Large variant |
Available sections in default template
Item-Based Sections
| Location Key | Name | Max Items | Has Image | Custom Fields |
|---|---|---|---|---|
homepage.stats |
Stats | 6 | No | — |
homepage.features |
Features | 12 | No | icon, link_url |
homepage.testimonials |
Testimonials | 12 | Yes | author_name, author_role, author_company, rating |
homepage.faq |
FAQ | 20 | No | — |
homepage.integrations |
Integrations | 16 | Yes | website_url |
homepage.tabs |
Platform | 8 | Yes | icon |
contact.contact_faq |
Contact FAQ | 20 | No | — |
Settings-Only Sections
| Location Key | Name | Settings |
|---|---|---|
homepage.hero |
Hero | title, subtitle, buttons, background image, overlay |
homepage.about |
About | heading, subtitle, description, image, button_text, button_url |
homepage.cta |
Call to Action | heading, subtitle, description, button_text, button_url, background_color |
contact.contact_info |
Contact Info | heading, subtitle, description, email, phone, address |
contact.contact_map |
Map | heading, theme, latitude, longitude |
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.
Navigation & Menus
| 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 |
|---|---|---|
@sectionEnabled('location') |
Check if section is enabled | @sectionEnabled('homepage.features') |
@hasSectionItems('location') |
Check if section has published items | @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
// Check if a section is enabled
section_enabled('homepage.features'); // → true/false
// Get section items for a location (cached)
$items = section_items('homepage.features');
// Get a section-specific setting
section_setting('homepage.hero', 'background_color', '#ffffff');
section_setting('homepage.cta', 'button_text', 'Get Started');
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');
Menu helpers
// 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
- Define options in
config/options.json - Values are stored in the database as
template.{name}.{key} - Read values in templates with
template_option()or the@optiondirective - 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 & Navigation
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
Footer with columns
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
Menu item properties
| 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">→</span>
{{-- RTL-aware transforms --}}
<span class="group-hover:translate-x-1 rtl:group-hover:-translate-x-1">→</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:
- Shows a file tree of all editable files
- Highlights overridden files with a badge
- Saves changes to the override directory (
_default/) - Allows reverting overrides to restore the original
- Shows a diff view comparing original vs. override
- 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:
- Override helpers at
_default/helpers.phpload first - Original helpers at
default/helpers.phpload second - 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.
Backend — PageResolver 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)
Frontend — app.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
- Create the file at
resources/js/templates/{template}/pages/{page-path}.tsx(or{page-path}/index.tsx) - The component receives the same Inertia props as the core page
- Import core UI components via
@/components/ui/*— they work everywhere - 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/indexauth/sign-up/indexauth/forgot-password/indexauth/reset-password/indexauth/verify-email/indexauth/confirm-password/indexauth/two-factor-challenge/indexauth/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_setting('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()andsetting_get()for configuration values
Creating a New Template
Step-by-step
- 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
│ └── sections.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 file per section in sections.json
└── lang/
└── en.json # Required
-
Create
template.jsonwith metadata, menu locations, and ad zones -
Create
layout.blade.phpwith 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')
-
Create page views (
home.blade.php,page.blade.php,blog/*.blade.php) that extend the layout -
Define options in
config/options.jsonfor admin customization -
Define sections in
config/sections.jsonand create matching section templates -
Add translations in
lang/en.json(and other languages) -
Add seed data in
database/seeders/SectionSeeder.phpfor your sections -
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
section_items()for section data — it's cached automatically - 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-brandutilities 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
@includefor reusable components - Use
@push/@stackfor 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
schemastack - 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
@csrfin all forms - Never output raw user input without sanitization