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 — signals & actions auto-injected by name
$c->view('counter.html.twig');
// PHP closure — no Twig needed, access via use()
$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
Signals and actions are auto-injected into every Twig render. You don't need to pass them manually — just register them and they're available by name:
$count = $c->signal(0, 'count');
$inc = $c->action(fn() => $count->setValue($count->int() + 1), 'inc');
// Signal 'count' → {{ count }} (a Signal object, call .int, .string, .id(), .url(), …)
// Action 'inc' → {{ inc }} (an Action object, call .url)
$c->view('counter.html.twig'); // no data needed
// Only pass data that isn't a signal or action:
$c->view('todo.html.twig', ['todos' => $todos]);
Explicit entries in the $data array take priority over auto-injected values —
useful when you need to pass a derived or renamed value for a specific render.
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 — count is auto-injected as a Signal object -->
<span data-text="${{ count.id() }}">{{ count.int }}</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:
<!-- name is auto-injected as a Signal object -->
<input data-bind="{{ name.id() }}" type="text">
<!-- Or use the bind() Twig function with the auto-injected Signal: -->
<input {{ bind(name) }} type="text">
Triggering actions
Use Datastar's event attributes to POST to an action URL:
<!-- inc is auto-injected as an Action object -->
<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