Build your own theme
A step-by-step tutorial for a Wake theme, from an empty folder to a ZIP you can install in the panel.
A theme decides how your website looks. Content, menus and settings stay the same when you switch themes. This tutorial builds a small theme called Harbour step by step. Every method it uses is listed in the templates reference.
You need a local Wake (installation),
a text editor and some HTML and CSS. Themes use
Blade, Laravel's template language:
{{ $value }} prints a value (escaped), @if, @foreach and @include
work as you would expect.
1. Start: copy Tide or begin empty
Copy Tide when you want to change an existing look. In the panel, open
Themes and click Duplicate next to Tide. Wake copies it to a new folder
named after the copy, for example themes/tide-copy/. Wake updates never
touch your copy.
Begin empty when you want to learn how a theme works, as this tutorial
does. Create the folder themes/harbour/ with this layout:
themes/harbour/
theme.yml name, description, options
screenshot.jpg optional, shown in the panel
templates/
layouts/default.blade.php
home.blade.php the start page
post.blade.php a post
page.blade.php a page
tag.blade.php a tag with its posts
404.blade.php page not found
cards/ optional, your own looks for cards
snippets/ optional, your own partials
assets/
harbour.css
lang/
de.json translations of the theme's own texts
Only templates/ is required. The folder name (harbour) is how Wake knows
the theme; use lowercase letters, digits and dashes.
2. theme.yml
name: Harbour
description:
en: Calm and maritime, with one accent colour.
de: Ruhig und maritim, mit einer Akzentfarbe.
version: 1.0.0
author: Jane Doe
license: MIT
options:
accent:
type: color
label: { en: Accent colour, de: Akzentfarbe }
default: '#3D5AFE'
show_tags:
type: toggle
label: { en: Tags on the start page, de: Schlagwörter auf der Startseite }
default: true
footer_text:
type: text
label: { en: Footer text, de: Text im Footer }
placeholder: { en: e.g. a short line about the site, de: z. B. eine Zeile über die Seite }
Texts can be one string or one per language; the panel shows the one for its
own language. A screenshot.jpg, .png or .webp next to theme.yml
appears in the theme overview; 1200 × 750 pixels work well.
Option types are color, text, textarea, toggle and select (with
options: { key: Label }). The values appear under Themes in the panel as
soon as the theme is active. See step 9.
3. The layout
Every page shares one layout. Create templates/layouts/default.blade.php:
<!doctype html>
<html lang="{{ $entry?->language() ?? $site->language() }}">
<head>
@wakeHead
<link rel="stylesheet" href="{{ theme_asset('harbour.css') }}">
<style>:root { --accent: {{ theme_option('accent') }} }</style>
</head>
<body>
<a class="skip" href="#main">{{ __('Skip to content') }}</a>
<header class="site-header">
<a class="site-title" href="{{ $site->url() }}">{{ $site->title() }}</a>
<nav aria-label="{{ __('Main menu') }}">
@foreach ($site->menu() as $item)
<a href="{{ $item->url() }}" @if ($item->isActive()) aria-current="page" @endif>{{ $item->title() }}</a>
@endforeach
</nav>
</header>
<main id="main">
@yield('content')
</main>
<footer class="site-footer">
<nav aria-label="{{ __('Footer') }}">
@foreach ($site->menu('footer') as $item)
<a href="{{ $item->url() }}">{{ $item->title() }}</a>
@endforeach
@foreach ($site->legalPages() as $page)
<a href="{{ $page->url() }}">{{ $page->title() }}</a>
@endforeach
@wakeConsentLink
</nav>
@if (theme_option('footer_text'))<p>{{ theme_option('footer_text') }}</p>@endif
</footer>
</body>
</html>
$site->legalPages() adds the imprint and the privacy policy once they are
published, unless the footer menu has them already. Every site needs them, so
keep this line.
What the three directives do:
| Directive | Why you need it |
|---|---|
@wakeHead |
Writes <meta charset>, the viewport, <title>, description, canonical URL, hreflang links, the RSS link, Open Graph tags, JSON-LD, the consent manager and the statistics script, plus anything plugins add. Do not write these tags yourself. |
@wakeConsentLink |
A "Cookie settings" link that opens the consent settings again. Visitors must be able to change their choice, so put it in the footer. |
@wakeServices |
A section for your privacy policy page that lists Wake's statistics, cookies and embedded services. Use it in a page template, or tell editors to put it on the privacy page. |
Every template gets $site (the website) and $entry (the current entry, or
null on the start page without content/pages/home). The entry is also
available under its type's name: $post, $page, $tag.
Menus
$site->menu() is the main menu, $site->menu('footer') the footer menu.
Editors arrange them under Menus in the panel; until then the main menu
follows the page tree (Arrange pages and menus). An item
can have one level of children:
@foreach ($site->menu() as $item)
<div class="nav-item">
<a href="{{ $item->url() }}" @if ($item->isActive()) aria-current="page" @endif>{{ $item->title() }}</a>
@if ($item->children()->isNotEmpty())
<div class="nav-sub">
@foreach ($item->children() as $child)
<a href="{{ $child->url() }}">{{ $child->title() }}</a>
@endforeach
</div>
@endif
</div>
@endforeach
The footer menu is empty until someone fills it. Tide then repeats the main
menu: $site->menu('footer')->whenEmpty(fn () => $site->menu()).
4. The start page
templates/home.blade.php renders /. It is also used for the page
content/pages/home, if there is one; then $entry is that page.
@extends('theme::layouts.default')
@section('content')
@php($posts = $site->posts()->paginate())
<section class="intro">
<h1>{{ $entry?->title() ?? $site->title() }}</h1>
@if ($entry)
{{ $entry->content() }}
@else
<p>{{ $site->description() }}</p>
@endif
</section>
<section class="posts">
@foreach ($posts as $post)
@include('theme::snippets.post-card', ['post' => $post])
@endforeach
</section>
@if ($posts->hasPages())
<nav class="pagination" aria-label="{{ __('Pages') }}">
@if ($posts->previousPageUrl())<a href="{{ $posts->previousPageUrl() }}" rel="prev">{{ __('Newer posts') }}</a>@endif
@if ($posts->nextPageUrl())<a href="{{ $posts->nextPageUrl() }}" rel="next">{{ __('Older posts') }}</a>@endif
</nav>
@endif
@if (theme_option('show_tags'))
<p class="tags">
@foreach ($site->tags()->take(20) as $tag)<a href="{{ $tag->url() }}">{{ $tag->title() }}</a>@endforeach
</p>
@endif
@endsection
Refer to other files of your theme with the theme:: prefix, as above.
$site->posts() is a query: you can
filter it (->tag('travel'), ->where('featured', true), ->limit(3))
before looping. paginate() takes per_page posts (10 by default) and reads
the page from ?page=.
Without a home.blade.php, the start page answers with 404. Every theme needs
one.
A post card with an image
templates/snippets/post-card.blade.php:
<article class="card">
@if ($post->thumbnail())
<a href="{{ $post->url() }}" tabindex="-1" aria-hidden="true">
{{ $post->thumbnail()->picture('', '(min-width: 960px) 33vw, 100vw') }}
</a>
@endif
<h2><a href="{{ $post->url() }}">{{ $post->title() }}</a></h2>
<p>{{ $post->excerpt(24) }}</p>
<time datetime="{{ $post->date()?->toDateString() }}">{{ $post->date()?->locale($post->language())->isoFormat('LL') }}</time>
</article>
thumbnail() is the preview image, or else the header image. picture()
writes a <picture> with WebP and a srcset of resized versions, so the
browser loads only the size it needs. Its second argument, sizes, tells
the browser how wide the image is shown.
Cards often crop images to the same shape with CSS. Editors set a focus
point per image in the panel (Media), and picture() adds it as
object-position, so the important part stays visible:
.card img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
The alt text from the file's details is used automatically. Here the image is
a decorative link next to the title, so the empty alt ('') is right.
5. Posts, pages and tags
templates/post.blade.php:
@extends('theme::layouts.default')
@section('content')
<article class="post">
<h1>{{ $post->title() }}</h1>
<p class="meta">
{{ $post->date()?->locale($post->language())->isoFormat('LL') }}
· {{ trans_choice(':count min read|:count min read', $post->readingTime()) }}
</p>
@if ($post->image()?->isImage())
<figure>{{ $post->image()->picture($post->title(), '100vw', ['loading' => 'eager']) }}</figure>
@endif
<div class="prose">{{ $post->content() }}</div>
@if ($post->tags()->isNotEmpty())
<p class="tags">@foreach ($post->tags() as $tag)<a href="{{ $tag->url() }}">{{ $tag->title() }}</a>@endforeach</p>
@endif
</article>
@endsection
templates/page.blade.php is the same without date and tags; use
$page->children() for a list of sub-pages. templates/tag.blade.php gets
the tag as $tag: loop over $tag->posts()->paginate().
Put class="prose" (or any class you like) around content() and style the
plain HTML elements inside it: headings, paragraphs, lists, blockquote,
figure, pre, tables. That is all Markdown produces, plus cards
(step 7).
6. Which template is used
Wake takes the first template that exists:
| Address | Templates, first match wins |
|---|---|
/ |
template field of pages/home → home → page → default |
| a post | its template field → post-{category template} → post → default |
| a page | its template field → page → default |
| a tag | its template field → tag → default |
an entry of a custom type, e.g. event |
its template field → event → default |
| a category page | category-{category template} → category → Wake's built-in list |
/search?q=… |
search → Wake's built-in page |
| not found | 404 → Wake's built-in page |
Two ways to give single entries their own look:
- The
templatefield. An editor writestemplate: landinginto the front matter of a page, and Wake usestemplates/landing.blade.phpif your theme has it. - Categories. Posts in the category Termine use
post-termine.blade.php, and the category pagecategory-termine.blade.php. A category can name another template in its settings (Categories in the panel). Tide shipspost-event.blade.phpandcategory-event.blade.phpfor events; look at them for dates, places and calendar links.
default.blade.php is a safety net for anything without its own template. If
neither exists, Wake uses a plain built-in page.
7. Cards
Editors write cards in Markdown:
::: callout 💡
Members get in free.
:::
Wake renders a card with the first view that exists:
- your theme's
templates/cards/{name}.blade.php, - a view registered by a plugin,
- Wake's built-in look (
callout,button,divider,embed), - otherwise a plain
<div class="card card-{name}">.
So a theme can restyle the built-in cards and invent new ones. The view gets
$name, $value (the text after the name), $attributes (key="value"
settings) and $content (the inside as HTML):
{{-- templates/cards/quote.blade.php, used as ::: quote author="Jane Doe" --}}
<figure class="quote">
<blockquote>{{ $content }}</blockquote>
@if (! empty($attributes['author']))<figcaption>{{ $attributes['author'] }}</figcaption>@endif
</figure>
Give a new card a description next to its view, templates/cards/quote.yml,
so editors get a form and an entry in the / menu, and assistants know how to
use it:
title: { en: Quote, de: Zitat }
hint: “
fields:
author: { type: text, label: { en: Author, de: Von } }
All keys are in the templates reference.
Leave embed alone unless you know what you do: Wake's version asks for
consent before it loads YouTube or Vimeo (consent).
8. Assets and translations
Assets. Put CSS, JavaScript, fonts and images into assets/ and link them
with theme_asset():
<link rel="stylesheet" href="{{ theme_asset('harbour.css') }}">
<link rel="preload" href="{{ theme_asset('fonts/inter.woff2') }}" as="font" type="font/woff2" crossorigin>
Wake copies changed files to public/assets/themes/harbour/ and adds a
version to the address (harbour.css?v=…), so browsers never keep an old
file. In CSS, refer to other assets with relative paths
(url(../fonts/inter.woff2)): when Wake copies a stylesheet, it copies the
files it points to as well, as long as they stay inside assets/. A
preload link as above still helps the browser start loading early.
Host fonts yourself rather than loading them from Google: it is faster and
needs no consent.
Translations. Write the theme's own texts in English inside __() and
translate them in lang/{code}.json:
{
"Skip to content": "Zum Inhalt springen",
"Main menu": "Hauptmenü",
"Newer posts": "Neuere Beiträge",
"Older posts": "Ältere Beiträge",
":count min read|:count min read": ":count Minute Lesezeit|:count Minuten Lesezeit"
}
The website language decides which file is used. Content itself is translated
by editors, not by the theme (Add a second language).
For a language switcher, copy templates/snippets/languages.blade.php from
Tide.
9. Theme options
The options from theme.yml appear under Themes in the panel. Templates
read them with theme_option('name'): the value chosen in the panel, else the
default from theme.yml. Values are stored in settings/general.yml under
theme_options; switching themes keeps them, but each theme only reads its
own keys.
Treat option values like any input. A colour is checked by the panel
(#RRGGBB), but if you write a value into CSS, check it again, as Tide does:
@php($accent = theme_option('accent'))
<style>:root { --accent: {{ preg_match('/^#[0-9a-fA-F]{6}$/', $accent) ? $accent : '#3D5AFE' }} }</style>
10. Good habits
- Dark mode: define colours as CSS variables and switch them in
@media (prefers-color-scheme: dark). Check the accent colour on both backgrounds. - Motion: wrap animations in
@media (prefers-reduced-motion: no-preference), or switch them off underreduce. - Accessibility: a skip link,
aria-currentin menus, visible focus styles, real headings in order, alt texts only where the image says something (picture($post->title())for a header image,''for a decorative thumbnail next to the title). - Images: always pass
sizes; use['loading' => 'eager', 'fetchpriority' => 'high']only for the first large image on a page. - Small phones: test at 360 pixels width; nothing may scroll sideways.
- No external requests without need: no CDN fonts, no tracking. Wake's
statistics and consent manager are already loaded by
@wakeHead.
11. Try it locally
- Select the theme: in the panel, Themes → Activate next to Harbour.
Or set
'theme' => 'harbour'insettings/config.php. - Run
php wake/artisan serveand open http://localhost:8000. - Wake caches finished pages. While signed in to the panel you always see
fresh pages; in a private window, run
php wake/artisan wake:clearafter changing a template (CLI). - Check the start page, a post with and without images, a page, a tag, a
category page,
/search?q=test, a missing address (404), both languages if the site has two, and a long title on a small screen.
12. Share it as a ZIP
Zip the theme folder:
cd themes && zip -r harbour.zip harbour -x '*.DS_Store'
Others install it under Themes → Install a theme (ZIP). Wake checks the ZIP before it unpacks anything:
- it must contain
templates/home.blade.php, either at the top or inside one top folder; - paths with
.., absolute paths and backslashes are refused.
The new folder is named after name in theme.yml (as a slug), with -2,
-3 … if that folder exists. A new theme is not activated automatically.
Themes are PHP (Blade) and run with full rights, just like plugins. Say so in your README, keep your theme free of surprises, and only install themes from people you trust.
Where to go next
- Templates reference: every variable and method
- Content format: fields, cards, categories, menus
- Tide: the default theme, a complete example
- Plugins: when a theme is not enough
source: docs/guides/build-a-theme.md