Latte Templates
PhlatPage uses Latte 3 as its template engine. This covers variable assignment, field rendering, layout inheritance, includes, filters, functions, and all template variables available in every view.
Variable assignment
Use {var} to assign without output. Using {$x = ...} assigns and also outputs the value.
{var $children = $page->children()} {* assigns, no output *}
{$title = 'Hello'} {* assigns AND outputs: Hello *}
Output
Latte HTML-escapes all output by default. Use |noescape only when you have already sanitized or trusted the value.
{$page->title} {* auto-escaped *}
{$html|noescape} {* bypass escaping — only safe HTML here *}
Field access
Content fields from data.json are accessed via $page->fieldName. This calls Page::__get() and returns a Field object.
{$page->body} {* Field::__toString() — decoded string, auto-escaped *}
{$page->body|render} {* rendered via the field type's view template *}
Always use ->empty() before rendering optional fields. A Field object is always truthy in PHP regardless of its value:
{if !$page->body->empty()}
<div class="prose">{$page->body|render}</div>
{/if}
Typed properties vs content fields
Page exposes typed readonly properties ($page->title, $page->uuid, $page->view, $page->status, $page->hidden, $page->created, $page->updated). These are plain strings or booleans — not Field objects.
{$page->title} {* string — typed property *}
{$page->body} {* Field — from __get, calls __toString() *}
{$page->hidden} {* bool — typed property *}
Declaring field types
Create site/views/{view}.json to declare which fields use which types:
{
"fields": [
{ "body": "markdown" },
{ "cover": "image" }
]
}
Undeclared fields return a plain Field with no type-specific decode or render behaviour. See Fields and Data Objects for details.
Filters
|render
Passes a Field through its type's view template. For untyped fields, HTML-escapes the decoded value.
{$page->body|render}
The field type's view.latte receives $field (the Field object) and $value (the decoded value).
|ago
Formats a date string as a human relative time.
{$page->created|ago} {* "3 days ago" *}
|date
Formats a date string with a PHP date format. Defaults to j M Y.
{$page->created|date} {* "4 Jan 2026" *}
{$page->created|date:'Y-m-d'} {* "2026-01-04" *}
Functions
icon()
Renders an inline SVG from phlat/icons/ by name. The second argument sets the class attribute on the <svg> element.
{icon('check')}
{icon('arrow-right', 'w-4 h-4 text-primary')}
Layout and blocks
Latte uses {layout} and {block} for template inheritance. Child templates declare a layout and fill its named blocks.
{* site/views/post.latte *}
{layout 'site/root.latte'}
{block body}
<article>
<h1>{$page->title}</h1>
<div class="prose">{$page->body|render}</div>
</article>
{/block}
The layout defines named blocks as output slots:
{* site/views/site/root.latte *}
<!DOCTYPE html>
<html>
<body>
{block body}{/block}
</body>
</html>
Use {include parent} inside a block to include the parent block's default content.
Includes and partials
{include} pulls in a partial. Paths resolve from the views root.
{include 'site/header.latte'}
{include 'ui/theme-picker.latte', options: ['default', 'dark']}
Variables listed after the comma are passed to the partial as locals. The partial does not inherit outer template variables automatically — pass everything it needs explicitly.
Loops
{foreach $page->children() as $child}
<a href="{$child->url()}">{$child->title}</a>
{/foreach}
{foreach $items as $i => $item}
{$i}: {$item->title}
{/foreach}
Latte provides an $iterator variable inside every {foreach}:
| Property | Value |
|---|---|
$iterator->first |
True on the first iteration |
$iterator->last |
True on the last iteration |
$iterator->counter |
1-based iteration count |
$iterator->counter0 |
0-based iteration count |
$iterator->odd / $iterator->even |
Alternating flag |
{foreach $items as $item}
<li class="{if $iterator->first}first{/if}">{$item->title}</li>
{/foreach}
Conditionals
{if $page->hidden}
<span>Hidden</span>
{elseif $page->status === 'draft'}
<span>Draft</span>
{else}
<span>Published</span>
{/if}
Template variables
Every view receives these automatically:
| Variable | Type | Description |
|---|---|---|
$page |
Page |
The current request page |
$app |
App |
The application instance |
The framework app controller provides additional variables:
| Variable | Type | Description |
|---|---|---|
$styles |
string |
URL to the compiled stylesheet |
$scripts |
array |
File objects for Alpine.js and HTMX |
$logo |
File |
The site logo SVG file |
$nav |
array |
Top-level navigation items |
View-specific controllers return additional variables. See Controllers for details.
HTMX patterns
PhlatPage is hypermedia-first — HTMX attributes go directly in Latte templates.
<button
hx-post="{$page->url()}"
hx-target="#result"
hx-swap="innerHTML"
>Submit</button>
Always include the CSRF token in mutating requests:
<form hx-post="{$page->url()}" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{$csrf}">
...
</form>
The $csrf variable is available in field render templates (provided by Field::render()). In regular views, generate it in a controller via Ses::csrf().
Alpine.js patterns
Alpine.js loads before the page body. Use x-data to scope reactive state.
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Content</div>
</div>
Use $persist for localStorage-backed state and x-effect for side effects:
<div
x-data="{ theme: $persist('default').as('site-theme') }"
x-effect="document.documentElement.dataset.theme = theme"
>
Latte vs PHP pitfalls
Field objects are always truthy. Use ->empty() or compare $field->value to check content:
{* Wrong — always true, even if empty *}
{if $page->body} ... {/if}
{* Correct *}
{if !$page->body->empty()} ... {/if}
{var} does not output; {$x = ...} does. A common mistake is using {$x = ...} expecting it to only assign.
String output is always escaped. If you see literal & in output, you've double-escaped. Use |noescape when the value is already safe HTML.