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 — 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