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 &amp; in output, you've double-escaped. Use |noescape when the value is already safe HTML.