Documentation menu

Templates

php-via supports two rendering paths that serve different purposes and can be mixed freely in the same app.

Two rendering paths

Shell — shell.html

A static .html file set with withShellTemplate(). php-via performs a simple string replacement of {{ placeholder }} tokens — not Twig. The shell is sent once per page load and never re-sent over SSE. Use it for the outer chrome: doctype, nav, scripts, footer.

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta data-signals='{{ signals_json }}'>
    {{ head_content }}
</head>
<body>
    {{ content }}
    <script type="module"
        src=".../datastar.js"></script>
    {{ foot_content }}
</body>
</html>

Views — Twig or closure

The page content, set with $c->view(). Re-rendered on every SSE update. Can be a Twig template (requires withTemplateDir()) or a plain PHP closure.

// Twig template (string name)
$c->view('counter.html.twig', [
    'count' => $count->int(),
    'id'    => $count->id(),
]);

// PHP closure — no Twig needed
$c->view(function () use ($count): string {
    return '<p>' . $count->int() . '</p>';
});

Alternative: Twig as the shell

Instead of a static shell.html, you can create your own Twig base template that defines the full outer document using Twig blocks. When a view extends it, Twig renders the complete <html>...</html> document — php-via detects this and skips the plain-HTML shell entirely.

{# templates/base.html.twig — you create this #}
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta data-signals='{"via_ctx":"{{ contextId }}","_disconnected":false}'>
    <title>{% block title %}{% endblock %}</title>
</head>
<body>
    <nav>...shared nav...</nav>
    <main>{% block content %}{% endblock %}</main>
    <script type="module" src="/datastar.js"></script>
</body>
</html>
{# templates/my-page.html.twig #}
{% extends "base.html.twig" %}

{% block title %}My App{% endblock %}

{% block content %}
    <h1>Hello</h1>
{% endblock %}

The key variable is {{ contextId }}, which is injected automatically by Context::render(). Don't confuse it with {{ context_id }}, which is the HtmlBuilder placeholder for the plain-HTML shell path.

This path is a good fit when you want Twig inheritance across multiple pages but don't need a separate static shell file. The downside: the base template is re-rendered on every SSE update (unlike a shell, which is sent once), so keep it lightweight.

Setup

Configure the template directory and optional shell in Config:

$app = new Via(
    (new Config())
        ->withTemplateDir(__DIR__ . '/templates')    // Twig views
        ->withShellTemplate(__DIR__ . '/shell.html') // optional plain-HTML shell
);

Skip withTemplateDir() if you use only PHP closures for views, and skip withShellTemplate() if your views extend base.html.twig.

Passing data to templates

Pass signal IDs and current values from PHP into Twig via $c->view()'s data array:

$count = $c->signal(0, 'count');
$inc   = $c->action(fn() => $count->setValue($count->int() + 1), 'inc');

$c->view('counter.html.twig', [
    'count_id'  => $count->id(),    // 'count'
    'count_val' => $count->int(),   // 0
    'inc_url'   => $inc->url(),     // '/_action/inc'
]);

Reactive text

Use data-text to keep an element in sync with a signal. Always seed the initial value to avoid a flash:

<!-- counter.html.twig -->
<span data-text="${{ count_id }}">{{ count_val }}</span>

Or use $signal->text() in PHP to generate the span directly, then output with | raw:

$c->view('counter.html.twig', [
    'count_span' => $count->text(), // '<span data-text="$count"></span>'
]);
{{ count_span | raw }}

Two-way binding

Use data-bind for inputs that should reflect and update a signal:

<input data-bind="{{ name_id }}" type="text">

<!-- Or use the bind() Twig function with the Signal object: -->
<input {{ bind(name_signal) }} type="text">

Pass the Signal object itself when using bind():

$c->view('form.html.twig', ['name' => $nameSignal]);

Triggering actions

Use Datastar's event attributes to POST to an action URL:

<button data-on:click="@post('{{ inc_url }}')">+1</button>

<!-- Pass data via query params -->
<button data-on:click="@post('{{ vote_url }}?option=tab')">Vote TAB</button>

Template inheritance

Standard Twig inheritance works as expected:

{# base.html.twig #}
<nav><a href="/">Home</a></nav>
<main>{% block content %}{% endblock %}</main>
{# page.html.twig #}
{% extends "base.html.twig" %}
{% block content %}
    <h1>My page</h1>
{% endblock %}

Blocks as SSE update targets

When you name a Twig block, you can tell $c->view() to send only that block on SSE updates while still rendering the full template on the initial page load:

$c->view(fn () => $c->render('page.html.twig', $data), block: 'demo');

This keeps SSE payloads small — the outer layout, nav, and static content are sent once on page load and never re-transmitted. Wrap the block content in <div id="via-block-{name}"> so Datastar can target it. See Views → Partial updates for details.

Disabling view caching

php-via caches view renders and skips re-rendering if signals haven't changed. Disable this when your view reads external state (e.g. getClients()):

$c->view('presence.html.twig', ['count' => count($app->getClients())], cacheUpdates: false);

Debugging

The dump() Twig function renders any variable as a <pre> block:

{{ dump(some_variable) }}

Next steps

  • Signals — reactive state primitives
  • Actions — server-side event handlers
  • Views — view lifecycle and caching
  • API Reference — complete method signatures