Creating a Theme
Build a custom Contensio theme from scratch — folder structure, template files, variables, hooks integration, and activation.
A Contensio theme is a folder inside packages/themes/{vendor}/{name}/ that contains a theme.json manifest and a views/ directory. No build step is required — themes are pure Blade templates.
Folder structure
packages/
themes/
acme/
mytheme/
theme.json
views/
layout.blade.php required — base HTML shell
index.blade.php required — ultimate fallback for every page
home.blade.php blog homepage (latest posts)
front-page.blade.php static homepage (when set in Reading settings)
single.blade.php any single content item
single-post.blade.php single blog post specifically
single-{type}.blade.php single item of a specific content type
page.blade.php standalone page
page-{slug}.blade.php specific page by slug
archive.blade.php content type archive
archive-{type}.blade.php specific content type archive
taxonomy.blade.php taxonomy term archive (generic)
category.blade.php hierarchical taxonomy archive
tag.blade.php flat taxonomy archive
taxonomy-{slug}.blade.php specific taxonomy archive
author.blade.php author profile page
search.blade.php search results
404.blade.php not found
partials/ your partial templates
assets/
style.css
app.js
screenshot.png
Contensio resolves templates using a most-specific-first hierarchy — see Template hierarchy for the full resolution order.
theme.json
{
"name": "acme/mytheme",
"label": "My Theme",
"description": "Clean, minimal theme for content-driven sites.",
"version": "1.0.0",
"author": "Your Name",
"screenshot": "assets/screenshot.png"
}
The name field must be unique and use vendor/theme format. It becomes the internal identifier stored in the database when the theme is activated.
layout.blade.php
The base shell that all other templates extend via @extends('theme::layout'). It must yield at least a content section.
<!doctype html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@yield('title', $site['name'])</title>
<meta name="description" content="@yield('meta_description', $site['tagline'] ?? '')">
<link rel="stylesheet" href="{{ asset('packages/themes/acme/mytheme/assets/style.css') }}">
{!! \Contensio\Support\Hook::render('contensio/frontend/head') !!}
@stack('head')
</head>
<body>
{!! \Contensio\Support\Hook::render('contensio/frontend/body-start') !!}
<header>
<a href="{{ route('contensio.home') }}">{{ $site['name'] }}</a>
</header>
<main>@yield('content')</main>
<footer>
© {{ date('Y') }} {{ $site['name'] }}
</footer>
@stack('scripts')
{!! \Contensio\Support\Hook::render('contensio/frontend/body-end') !!}
</body>
</html>
The three Hook::render() calls are how plugins inject <meta> tags, analytics snippets, or scripts into your theme. Always include them — plugins rely on them.
index.blade.php
The ultimate fallback. Rendered whenever no more specific template matches. A minimal implementation:
@extends('theme::layout')
@section('title', $site['name'])
@section('content')
<main class="max-w-3xl mx-auto px-6 py-12">
@if(! empty($posts) && $posts->isNotEmpty())
@foreach($posts as $post)
@include('theme::partials.post-card', ['post' => $post])
@endforeach
{{ $posts->links() }}
@else
<h1>{{ $site['name'] }}</h1>
@if($site['tagline'])
<p>{{ $site['tagline'] }}</p>
@endif
@endif
</main>
@endsection
home.blade.php
Rendered for the blog homepage (latest posts). Only used when the homepage is set to Latest posts in Reading settings — or when front-page.blade.php does not exist.
@extends('theme::layout')
@section('title', $site['name'])
@section('content')
<div class="max-w-3xl mx-auto px-6 py-12">
<h1 class="text-3xl font-bold mb-8">Latest Posts</h1>
@forelse($posts as $post)
@include('theme::partials.post-card', ['post' => $post])
@empty
<p>No posts yet.</p>
@endforelse
{{ $posts->links() }}
</div>
@endsection
front-page.blade.php
Rendered when the homepage is set to A static page in Reading settings. Receives the same variables as page.blade.php. Use this for custom landing pages.
@extends('theme::layout')
@section('title', apply_filters('contensio/frontend/page-title', $translation->title . ' — ' . $site['name'], $content))
@section('content')
<article class="max-w-3xl mx-auto px-6 py-12">
<h1>{{ $translation->title }}</h1>
@php ob_start(); @endphp
<div class="contensio-post-body">
@foreach($content->blocks ?? [] as $block)
@include('theme::partials.block', ['block' => $block, 'langId' => $lang?->id])
@endforeach
</div>
@php $body = ob_get_clean(); @endphp
{!! apply_filters('contensio/content/body', $body, $content) !!}
</article>
@endsection
single-post.blade.php
The blog post template. Can access all post-specific data including the author, blocks, custom fields, and comments.
@extends('theme::layout')
@section('title', apply_filters('contensio/frontend/page-title', $translation->title . ' — ' . $site['name'], $content))
@section('content')
<article class="max-w-3xl mx-auto px-6 py-12">
{{-- Featured image --}}
@if($content->featuredImage)
<div class="aspect-video rounded-xl overflow-hidden mb-8">
<img src="{{ Storage::disk($content->featuredImage->disk)->url($content->featuredImage->file_path) }}"
alt="{{ $translation->title }}"
class="w-full h-full object-cover">
</div>
@endif
{{-- Meta row --}}
<div class="text-sm text-gray-500 mb-4">
@if($content->author)
<a href="{{ route('contensio.author', $content->author->id) }}">{{ $content->author->name }}</a>
·
@endif
<time datetime="{{ $content->published_at?->toDateString() }}">
{{ $content->published_at?->format('M d, Y') }}
</time>
{!! \Contensio\Support\Hook::render('contensio/frontend/post-meta', $content, $translation) !!}
</div>
<h1>{{ $translation->title }}</h1>
@if($translation->excerpt)
<p class="text-lg text-gray-500 mb-6">{{ $translation->excerpt }}</p>
@endif
{!! \Contensio\Support\Hook::render('contensio/frontend/post-before-content', $content, $translation) !!}
{{-- Content blocks --}}
@php ob_start(); @endphp
<div class="contensio-post-body">
@foreach($content->blocks ?? [] as $block)
@include('theme::partials.block', ['block' => $block, 'langId' => $lang?->id])
@endforeach
</div>
@php $body = ob_get_clean(); @endphp
{!! apply_filters('contensio/content/body', $body, $content) !!}
{!! \Contensio\Support\Hook::render('contensio/frontend/post-after-content', $content, $translation) !!}
</article>
{{-- Comments --}}
@include('contensio::frontend.partials.comments', [
'content' => $content,
'comments' => $comments,
'commentsEnabled' => $commentsEnabled,
])
@endsection
page.blade.php
Standalone pages — about, contact, etc.
@extends('theme::layout')
@section('title', apply_filters('contensio/frontend/page-title', $translation->title . ' — ' . $site['name'], $content))
@section('content')
<article class="max-w-3xl mx-auto px-6 py-12">
<h1>{{ $translation->title }}</h1>
@php ob_start(); @endphp
<div class="contensio-post-body">
@foreach($content->blocks ?? [] as $block)
@include('theme::partials.block', ['block' => $block, 'langId' => $lang?->id])
@endforeach
</div>
@php $body = ob_get_clean(); @endphp
{!! apply_filters('contensio/content/body', $body, $content) !!}
</article>
@endsection
taxonomy.blade.php
Term archive for any taxonomy — categories, tags, genres, etc.
@extends('theme::layout')
@section('title', ($termTrans?->name ?? 'Archive') . ' — ' . $site['name'])
@section('content')
<div class="max-w-3xl mx-auto px-6 py-12">
<h1>{{ $termTrans?->name }}</h1>
@if($termTrans?->description)
<p>{{ $termTrans->description }}</p>
@endif
@forelse($posts as $post)
@include('theme::partials.post-card', ['post' => $post])
@empty
<p>No posts in this category.</p>
@endforelse
{{ $posts->links() }}
</div>
@endsection
category.blade.php and tag.blade.php work the same way — use them to apply different layouts for hierarchical vs flat taxonomies. The simplest approach is to just delegate to taxonomy:
{{-- category.blade.php --}}
@include('theme::taxonomy')
search.blade.php
@extends('theme::layout')
@section('title', $searched ? 'Search: ' . $query . ' — ' . $site['name'] : 'Search — ' . $site['name'])
@section('content')
<div class="max-w-3xl mx-auto px-6 py-12">
<form action="{{ route('contensio.search') }}" method="GET">
<input type="text" name="q" value="{{ $query }}" placeholder="Search…">
<button type="submit">Search</button>
</form>
@if($searched)
@forelse($results as $trans)
<article>
<h2><a href="{{ route('contensio.post', $trans->slug) }}">{{ $trans->title }}</a></h2>
@if($trans->excerpt)
<p>{{ $trans->excerpt }}</p>
@endif
</article>
@empty
<p>No results for "{{ $query }}".</p>
@endforelse
{{ $results->links() }}
@endif
</div>
@endsection
author.blade.php
@extends('theme::layout')
@section('title', $user->name . ' — ' . $site['name'])
@section('content')
<div class="max-w-3xl mx-auto px-6 py-12">
<div class="flex gap-6 mb-10">
@if($user->avatar_path)
<img src="{{ Storage::disk('public')->url($user->avatar_path) }}"
alt="{{ $user->name }}"
class="w-20 h-20 rounded-full object-cover">
@endif
<div>
<h1>{{ $user->name }}</h1>
@if($user->bio)<p>{{ $user->bio }}</p>@endif
</div>
</div>
@forelse($posts as $post)
@include('theme::partials.post-card', ['post' => $post])
@empty
<p>No posts yet.</p>
@endforelse
</div>
@endsection
Available variables
Global (every view)
| Variable | Type | Description |
|---|---|---|
$site |
array | name, tagline, logo_url, favicon_url, og_image_url |
$lang |
Language|null | Active language model — id, code, name, direction |
home.blade.php, archive.blade.php, archive-{type}.blade.php
| Variable | Type | Description |
|---|---|---|
$posts |
Paginator | Published Content items with translations, featuredImage |
$postType |
ContentType|null | The content type being listed (archive only) |
front-page.blade.php, page.blade.php, page-{slug}.blade.php
| Variable | Type | Description |
|---|---|---|
$content |
Content | The page content model |
$translation |
ContentTranslation | Active language translation |
$blocks |
array | Content blocks (also at $content->blocks) |
single.blade.php, single-post.blade.php, single-{type}.blade.php
| Variable | Type | Description |
|---|---|---|
$content |
Content | The content model |
$translation |
ContentTranslation | Active language translation |
$postType |
ContentType | The content type |
$fieldGroups |
Collection | Custom field groups |
$fieldValues |
array | Field values keyed by {fieldId}:{langId} |
$comments |
Collection | Approved comments |
$commentsEnabled |
bool | Whether commenting is open |
taxonomy.blade.php, category.blade.php, tag.blade.php, taxonomy-{slug}.blade.php
| Variable | Type | Description |
|---|---|---|
$taxonomy |
Taxonomy | The taxonomy model |
$taxTrans |
TaxonomyTranslation | Active translation |
$term |
Term | The term model |
$termTrans |
TermTranslation | Active term translation |
$posts |
Paginator | Posts tagged with this term |
author.blade.php
| Variable | Type | Description |
|---|---|---|
$user |
User | The author — name, bio, avatar_path, created_at |
$posts |
Paginator | Published posts by this author |
search.blade.php
| Variable | Type | Description |
|---|---|---|
$results |
Paginator | ContentTranslation records matching the query |
$query |
string | The search string |
$searched |
bool | true if a search was submitted |
Rendering content blocks
Use the block partial that comes with Contensio:
@foreach($content->blocks ?? [] as $block)
@include('theme::partials.block', ['block' => $block, 'langId' => $lang?->id])
@endforeach
The theme::partials.block partial is provided by the default theme and can be overridden in your theme by creating your own partials/block.blade.php. Each block is an array with a type key — you can handle specific block types yourself:
{{-- partials/block.blade.php --}}
@switch($block['type'])
@case('heading')
<h2>{{ $block['data']['text'] ?? '' }}</h2>
@break
@case('paragraph')
<p>{{ $block['data']['text'] ?? '' }}</p>
@break
@default
@include('contensio::theme.blocks.' . $block['type'], ['block' => $block])
@endswitch
Apply the content body filter
Wrap rendered blocks in apply_filters('contensio/content/body', ...) so plugins can modify output (inject ads, add reading time, wrap in custom markup, etc.):
@php ob_start(); @endphp
<div class="contensio-post-body">
@foreach($content->blocks ?? [] as $block)
@include('theme::partials.block', ['block' => $block, 'langId' => $lang?->id])
@endforeach
</div>
@php $body = ob_get_clean(); @endphp
{!! apply_filters('contensio/content/body', $body, $content) !!}
Using hooks in your theme
Contensio provides hooks specifically for themes. Call them from your templates — plugins register callbacks against these names and depend on your theme calling them.
contensio/frontend/head
Fired inside <head>, before </head>. Use for meta tags, stylesheets, preloads.
{{-- layout.blade.php --}}
{!! \Contensio\Support\Hook::render('contensio/frontend/head') !!}
@stack('head')
</head>
A plugin hooking into it:
use Contensio\Support\Hook;
Hook::add('contensio/frontend/head', function (): string {
return '<link rel="stylesheet" href="https://cdn.example.com/plugin.css">';
});
contensio/frontend/body-start
Fired immediately after <body>. Use for analytics init, tag manager <noscript> fallbacks.
<body>
{!! \Contensio\Support\Hook::render('contensio/frontend/body-start') !!}
contensio/frontend/body-end
Fired just before </body>, after @stack('scripts'). Use for deferred scripts, chat widgets.
@stack('scripts')
{!! \Contensio\Support\Hook::render('contensio/frontend/body-end') !!}
</body>
contensio/frontend/post-meta
Fired inside the post meta row (author, date, reading time). Use for reading time, word count, print buttons.
{{-- single-post.blade.php — inside the meta row --}}
{!! \Contensio\Support\Hook::render('contensio/frontend/post-meta', $content, $translation) !!}
Each item is conventionally preceded by a <span>·</span> separator.
contensio/frontend/post-before-content
Fired between the excerpt and the block content area. Use for table of contents, paywalls, content warnings.
{!! \Contensio\Support\Hook::render('contensio/frontend/post-before-content', $content, $translation) !!}
contensio/frontend/post-after-content
Fired after the block content area, still inside <article>. Use for author bios, related posts, newsletter CTAs.
{!! \Contensio\Support\Hook::render('contensio/frontend/post-after-content', $content, $translation) !!}
contensio/frontend/page-title
A filter — modify the <title> tag value.
@section('title', apply_filters('contensio/frontend/page-title', $translation->title . ' — ' . $site['name'], $content))
add_filter('contensio/frontend/page-title', function (string $title, $content): string {
return 'Read: ' . $title;
}, 10);
contensio/content/body
A filter — modify the rendered HTML of any content item before it reaches the browser.
add_filter('contensio/content/body', function (string $html, $content): string {
return $html . '<div class="related-posts">…</div>';
}, 10);
Theme assets
Put CSS, JS, and images inside assets/ in your theme folder. Reference them in views:
<link rel="stylesheet" href="{{ asset('packages/themes/acme/mytheme/assets/style.css') }}">
<script src="{{ asset('packages/themes/acme/mytheme/assets/app.js') }}" defer></script>
Activating a theme
Go to Appearance → Themes in the admin panel and click Activate. Or programmatically:
use Contensio\Models\Setting;
Setting::set('core', 'active_theme', 'acme/mytheme');
See also
- Template hierarchy — how Contensio picks which template to render
- Contensio themes vs WordPress themes — side-by-side comparison for WP developers
- Hook system — full list of available hooks