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 Record::__get() (inherited by Page) and returns a FieldValue object.
{$page->body} {* FieldValue::__toString(), decoded string, auto-escaped *}
{$page->body|render} {* rendered via the field type's render template *}
Always use ->empty() before rendering optional fields. A FieldValue 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->uuid, $page->view, $page->status, $page->hidden, $page->created, $page->updated). These are plain strings or booleans, not FieldValue objects. title is not one of them, it's an ordinary content field from data.json like any other.
{$page->title} {* FieldValue, from __get, calls __toString() *}
{$page->view} {* string, typed property *}
{$page->hidden} {* bool, typed property *}
Declaring field types
A field's type lives on its own named definition, site/fields/{name}/index.json:
// site/fields/body/index.json
{ "label": "Body", "type": "markdown", "options": {} }
A view's own index.json references fields by name under a fields key, to override options per-view, not to declare the type:
// site/views/post/index.json
{ "fields": { "body": {}, "cover": {} } }
See Fields and Data Objects for details.
Filters
|render
Passes a FieldValue through its type's render template, checked in order: site/views/fields/{view}/{name}.latte, site/views/fields/{name}.latte, site/views/fields/{type}.latte. If none exist, it falls back to the plain decoded value.
{$page->body|render}
The render template receives $field (the FieldValue), $value (the decoded value), $page, and $request.
|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/index.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/index.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/badge.latte', label: 'New'}
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 $page automatically. $app is not implicitly available, a controller has to pass it explicitly if a template needs it.
The global controller (views/controller.php, the app view) supplies whatever the site defines there. This project's own global controller provides $page, $assets, $logo, $logo_icon, $nav, $scripts, $styles, a different site is free to return a different set.
View-specific controllers return additional variables, merged on top, later keys win on collision. 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, via the csrf() template function:
<form hx-post="{$page->url()}" hx-swap="outerHTML">
<input type="hidden" name="_csrf" value="{csrf()}">
...
</form>
csrf() is a global Latte function ($app->session->csrf() under the hood), available in every template, no controller wiring needed.
Vanilla JS interactivity
There's no client-side framework, no Alpine, no Vue, no React. Page interactivity is plain JS, bundled per-area (admin.js) and loaded via <script defer>. Use data attributes to scope behaviour and event delegation on document so it survives HTMX swaps:
<button type="button" data-toggle-target="#panel">Toggle</button>
<div id="panel" class="hidden">Content</div>
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-toggle-target]');
if (btn) document.querySelector(btn.dataset.toggleTarget).classList.toggle('hidden');
});
For localStorage-backed state, read/write localStorage directly inside the delegated event handler.
Latte vs PHP pitfalls
FieldValue 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.