Contensio logo

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>
        &copy; {{ 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>
            &middot;
        @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>&middot;</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