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