Templates
How a theme is built and what its Blade templates can use.
A theme is a folder in themes/. The active one is set with the theme
setting.
themes/tide/
theme.yml name, version, author
templates/ Blade templates
layouts/default.blade.php
home.blade.php the start page (required)
post.blade.php a post
post-event.blade.php a post in a category with the template "event"
page.blade.php a page
tag.blade.php a tag with its posts
category.blade.php a category page
event.blade.php an entry of the custom type "event"
landing.blade.php any name, chosen with the template field
default.blade.php anything without its own template
search.blade.php search results
404.blade.php page not found
cards/ optional looks for cards
snippets/ your own partials
assets/ CSS, JavaScript, fonts, images
lang/de.json translations of the theme's own texts
screenshot.jpg optional, shown in the panel
Only templates/ is required, and in it home.blade.php: without it the
start page answers with 404. A step-by-step tutorial is in
Build your own theme.
theme.yml
name: Harbour
description: { de: Ruhig und maritim, en: Calm and maritime }
version: 1.0.0
author: Jane Doe
options: # shown in the panel under Themes
accent:
type: color # color, text, textarea, toggle, select
label: { de: Akzentfarbe, en: Accent colour }
default: '#3D5AFE'
show_tags:
type: toggle
label: Show tags
default: true
Templates read options with theme_option('accent'): the value chosen in the
panel, else the default. A screenshot.jpg (or .png, .webp) in the theme
folder appears in the panel's theme overview.
Templates use Blade. Refer to other templates
of the theme with the theme:: prefix:
@extends('theme::layouts.default')
@include('theme::snippets.post-card', ['post' => $post])
Which template is used
| Address | Templates, first match wins |
|---|---|
/ |
template field of pages/home → home → page → default |
| an entry | its template field → post-{category template} (posts with a category) → its type (post, page, tag, 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 |
When no template matches an entry, Wake renders it with a plain built-in
page. $entry->template() returns the template field, else the type.
Variables
| Variable | Available | Contents |
|---|---|---|
$site |
always | the website (see below) |
$entry |
always, null on a start page without pages/home |
the current entry |
$post, $page, $tag, $event … |
matching type | the same entry under its type's name |
$category |
category pages | the category |
$query, $results |
search |
the search words and the matching entries |
Helpers
| Helper | Returns |
|---|---|
theme_asset('style.css') |
the public address of a file in assets/, see assets |
theme_option('accent', $default) |
an option of the active theme: the value from the panel, else the default from theme.yml, else $default |
__('Text'), trans_choice('… | …', $n) |
the theme's own texts, see translations |
app(\Wake\Localization\Languages::class) |
languages: all(), codes(), isMultilingual(), current(), url($path, $code) |
$site
| Method | Returns |
|---|---|
$site->title(), $site->description() |
from content/site.{lang}.md |
$site->get('field') or $site->field |
any field of the site file |
$site->url($lang = null) |
address of the start page |
$site->language() |
code of the current language |
$site->languages() |
all languages, code → name |
$site->posts() |
published posts, newest first (a query) |
$site->pages() |
published top-level pages |
$site->menu($name = 'main') |
a menu: main or footer |
$site->tags() |
all tags used by published posts, most used first |
$site->entries('event') |
a query for any content type |
$site->find('posts/hello-world') |
one entry by its folder |
$site->imprint(), $site->privacy() |
the imprint and privacy policy (settings legal.*) in the current language, or null until published |
$site->legalPages() |
both, published and not already in the footer menu: what a footer should link |
Menus
$site->menu() returns the main menu, $site->menu('footer') the footer
menu, as a collection of menu items. Without a hand-made main menu, the main
menu shows the top-level pages with menu: true, with their sub-pages that
have menu: true.
| Method | Returns |
|---|---|
title() |
the label, or the page title |
url() |
the address |
page() |
the page entry, null for links |
children() |
the sub-items (one level) |
isActive() |
whether the current address is this page or below it |
isExternal() |
whether the link leads to another site |
@foreach ($site->menu('footer')->whenEmpty(fn () => $site->menu()) as $item)
<a href="{{ $item->url() }}">{{ $item->title() }}</a>
@endforeach
Entries
| Method | Returns |
|---|---|
title(), slug(), url(), path() |
title and address |
template() |
the template field, else the type |
get('field', $default) or $entry->field |
any front matter field, also nested: get('seo.title') |
content() |
the text as HTML |
body() |
the text as Markdown |
excerpt($words = 40) |
excerpt field or the start of the text |
readingTime() |
minutes, rounded |
date() |
publication date as Carbon or null |
modifiedAt() |
last change of the file |
status(), isPublished(), isDraft(), isScheduled() |
publication state |
tags() |
the entry's tags |
image($name = null) |
the header image (cover), or the named file |
thumbnail() |
the preview image (thumbnail), or else the header image |
file('name.pdf'), media() |
files in the entry's folder |
language(), translations(), translation('de'), hasTranslation('de') |
languages: translations() lists the codes, translation() returns the entry in that language or null |
requestedLanguage() |
the language of the address, which differs from language() for a fallback |
isFallback() |
true when shown in the default language because a translation is missing |
parent(), children() |
page tree |
id(), type() |
folder below content/ and content type |
Pages also have isHome() and isListed(). Tags have posts() and
isVirtual() (no file in content/tags/).
Queries
Queries work like Laravel's query builder and can be looped over directly:
@foreach ($site->posts()->tag('travel')->limit(3) as $post)
<a href="{{ $post->url() }}">{{ $post->title() }}</a>
@endforeach
| Method | Effect |
|---|---|
published(), drafts(), scheduled(), any() |
by status |
tag('travel'), author('dennis') |
by tag or author |
where('location', 'Bochum'), where('price', '>', 10) |
by field. Operators: =, !=, >, >=, <, <=, in, contains |
listed() (pages with menu: true), childrenOf($id) |
page tree, in order |
latest(), oldest(), orderBy('title') |
order |
limit(5), offset(10) |
slice |
in('de'), withFallback() |
language |
get(), first(), count(), exists() |
results |
paginate($perPage = null) |
a Laravel paginator, page from ?page= |
A paginator loops like a query and offers hasPages(), currentPage(),
lastPage(), previousPageUrl() and nextPageUrl(). Without an argument it
takes per_page entries (configuration).
In PHP code the same queries start from the models:
Wake\Content\Models\Post::published()->latest()->get().
Images
{{ $post->image()->picture($post->title(), '(min-width: 960px) 960px, 100vw') }}
| Method | Returns |
|---|---|
picture($alt, $sizes) |
<picture> with a WebP source and a responsive <img> |
img($alt, $sizes, $attributes) |
responsive <img> with srcset, width, height, loading="lazy" |
url($width = null, $format = null) |
address of the original or a resized version |
srcset($format = null) |
the srcset value |
width(), height(), isImage(), filesize(), name() |
file details |
alt($language = null), caption($language = null), credit() |
details from the file's sidecar file, '' when not set |
focus() |
the focus point as a CSS position, 50% 50% by default |
meta() |
everything in the sidecar file |
img() and picture() use the alt text from the details when there is one,
and add object-position for a focus point, so cropped images
(object-fit: cover) keep the important part in view. See
file details.
The head
Put @wakeHead into <head>. It writes <meta charset>, the viewport, the
title, description, canonical URL, hreflang links for every translation, the
RSS link, Open Graph tags, JSON-LD for posts and events, the consent manager,
the statistics script and anything plugins add. Do not write
these tags yourself.
Editors can adjust it per entry with front matter fields:
| Field | Effect |
|---|---|
seo.title |
replaces the <title> (default: entry title · site title) |
seo.description |
replaces the description (default: the excerpt) |
noindex: true |
adds <meta name="robots" content="noindex"> |
thumbnail / cover |
the image for Open Graph |
<head>
@wakeHead
<link rel="stylesheet" href="{{ theme_asset('tide.css') }}">
</head>
Categories
| Address | Templates, first match wins |
|---|---|
| a post with a category | its template field → post-{template of the category} → post → default |
| the category page | category-{template} → category → Wake's built-in list |
The category page receives $category:
| Method | Returns |
|---|---|
title(), description(), slug(), url() |
in the current language |
posts() |
published posts: upcoming first for date categories, else newest first |
past() |
posts whose date has passed (date categories) |
fields(), label('start') |
field definitions and labels |
template(), dateField(), icon() |
settings |
For recurring events, $category->occurrences($from, $to) returns one row
per date (entry, start, end), earliest first. On a post,
$post->recurrence() gives the rule (isRecurring(), between(), next(),
last(), describe()), and $post->nextOccurrence() the next date with
start and end. upcoming() and past() in queries take repetitions into
account; a series without end never becomes past.
On posts, $post->category() returns the category, and the category's fields
are available like any other field: $post->start, $post->get('location').
In queries: $site->posts()->category('termine')->upcoming('start'), or
->past('start'). $site->categories() lists all categories.
Every post with a start field also has a calendar file at {address}.ics,
and @wakeHead adds schema.org Event data for it.
Search
/search?q=… (and /de/search?q=… per language) renders the theme's
search.blade.php with $query and $results, a list of published entries
that contain every word of the query, best matches first. Wake keeps a small
search index per language in temp/cache/search-{lang}.php and rebuilds it
when content changes. Search pages are never cached and carry noindex.
The address search is therefore reserved; a page with the slug search is
not reachable.
Consent and privacy
| Directive | Output |
|---|---|
@wakeConsentLink |
a "Cookie settings" link that opens the consent settings. Put it in the footer |
@wakeServices |
a section for the privacy policy listing Wake's statistics, cookies and embedded services |
@wakeHead already loads the consent manager and the tracking script. See
consent.
Forms
Pages are cached and run without a session, so a form on the website cannot use Laravel's CSRF token. Wake protects public forms without one:
<form method="post" action="{{ url('contact') }}">
@wakeForm('contact')
<input name="email" type="email" required>
<button>Send</button>
</form>
Route::post('contact', ContactController::class)->middleware('wake.form:contact');
@wakeForm('name') adds a stamp signed with the application key (the form's
name and when the page was made) and a hidden honeypot field. The middleware
wake.form:name refuses the request with 422 when the honeypot is filled, the
stamp is forged or belongs to another form, or the form was sent within two
seconds of loading or more than seven days later; and with 429 after ten
submissions of that form from one visitor (hashed IP address) within ten
minutes. Validate the fields themselves as usual. Forms are added by
plugins, which register the route.
Assets
theme_asset('tide.css') returns the public address of a file in assets/.
Wake copies changed files to public/assets/themes/{theme}/ by itself and adds
a version to the address, so browsers never keep an old file. Inside 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/.
Cards
Wake renders a card with the first view that
exists: the theme's templates/cards/{name}.blade.php, a view registered by a
plugin, Wake's built-in look (callout, button, divider,
embed), else a plain <div class="card card-{name}">. So a theme can
restyle built-in cards and add new ones. The template receives:
| Variable | Contents |
|---|---|
$name |
name of the card |
$value |
free text after the name |
$attributes |
key="value" settings |
$content |
the card's content as HTML |
$card |
its description, or null |
$images |
the media files (Wake\Media\Media, or null if missing) of its image fields, by field |
{{-- templates/cards/callout.blade.php --}}
<aside class="callout">{{ $value }} {{ $content }}</aside>
Card descriptions
A {name}.yml next to the card's view tells the editor and AI assistants what
the card is for and which settings it has. The editor then shows a small form
instead of the raw line and offers the card in its / menu; assistants read
it with the MCP tool content_cards.
# templates/cards/member.yml, used as ::: member Jane Doe role="Chair" photo="jane.jpg"
title: { en: Board member, de: Vorstandsmitglied }
description:
en: A person of the board with photo and role.
de: Eine Person aus dem Vorstand mit Foto und Amt.
hint: 👤 # short sign in the / menu
value: # the text after the name; leave out if unused
label: { en: Name, de: Name }
required: true
content: false # true (default): the card holds Markdown
fields:
role: { type: text, label: { en: Role, de: Amt }, required: true }
photo: { type: image, label: { en: Photo, de: Foto } }
tone:
type: select
default: light
options: { light: { en: Light, de: Hell }, dark: { en: Dark, de: Dunkel } }
| Key | Meaning |
|---|---|
title, description |
text, or one text per language |
hint |
up to three characters for the / menu |
value |
label, placeholder, required for the text after the name |
content |
false hides the text area of the card in the editor |
menu |
false keeps the card out of the / menu |
fields |
settings written as key="value", in this order. Keys are lowercase. type: text, url, image (a file from the media library, or a name in the entry's media folder), select with options, toggle (written as "true"), number, date, color. Also label, placeholder, required, default |
{{-- templates/cards/member.blade.php --}}
<figure class="member member-{{ $attributes['tone'] ?? 'light' }}">
@if ($images['photo'] ?? null){{ $images['photo']->img($value) }}@endif
<figcaption><b>{{ $value }}</b> {{ $attributes['role'] ?? '' }}</figcaption>
</figure>
Required settings are marked in the editor. They never block saving; when
someone publishes an entry with a card that leaves them empty, Wake names the
card and asks first, and content_publish returns them as card_warnings.
Wake reads its own descriptions (for callout, button, embed and
divider) first, then those of running plugins (views/cards/{name}.yml),
then the theme's; the theme wins. A description that cannot be read is shown
on the Themes page in the panel. Settings a description does not know stay
in the text as they are.
Translations
Texts of the theme itself go through Laravel's __() helper with English as the
key. Translations live in lang/{code}.json of the theme; the language of the
page decides which one is used:
{ "Older posts": "Ältere Beiträge" }
source: docs/reference/templates.md