Composition
The composition API lets you define reactive pages and components as plain PHP classes annotated with attributes, instead of writing imperative closure-based setup code. It is fully additive — the existing closure API is unchanged.
Reactive state is declared with a single #[Signal] attribute whose scope is a
parameter. Server-only state, the page broadcast scope, actions, and lifecycle hooks each have
their own attribute:
| Attribute | Kind | Client-writable | Auto-broadcasts |
|---|---|---|---|
#[Signal] | TAB signal | yes | — |
#[Signal(Scope::ROUTE)] | ROUTE signal | no | yes → same route |
#[Signal(Scope::SESSION)] | SESSION signal | no | yes → user's tabs |
#[Signal(Scope::GLOBAL)] | GLOBAL signal | no | yes → all users |
#[Persist] | server-only property | no | — |
#[Broadcast(Scope::X)] | class — primary scope | — | — |
#[Action] | method — action | — | — |
#[OnDisconnect] / #[OnCleanup] | method — lifecycle | — | — |
Mounting a page
Define a class with reactive properties and annotated action methods, then register it with
Via::mount():
use Mbolli\PhpVia\Attributes\Action;
use Mbolli\PhpVia\Attributes\Signal;
use Mbolli\PhpVia\Context;
use Mbolli\PhpVia\Via;
final class Counter {
#[Signal]
public int $count = 0;
#[Signal]
public int $step = 1;
public function view(Context $ctx): void {
$ctx->view('counter.html.twig');
}
#[Action]
public function increment(Context $ctx): void {
$this->count += $this->step;
}
#[Action]
public function reset(Context $ctx): void {
$this->count = 0;
}
}
$app->mount(Counter::class, '/counter');
The Twig template is identical to the closure API — named signals and actions are auto-injected as variables:
{# counter.html.twig #}
<div id="counter">
<p>Count: <span data-text="${{ count.id }}">{{ count.int }}</span></p>
<label>Step: <input type="number" data-bind="{{ step.id }}"></label>
<button data-on:click="@post('{{ increment.url }}')">+</button>
<button data-on:click="@post('{{ reset.url }}')">Reset</button>
</div>
Class-based components
Pass a class name string to $ctx->component() to use the same API for reusable
sub-contexts. Mounting the same class multiple times with different namespaces gives each instance
independent signal IDs:
public function view(Context $ctx): void {
$cats = $ctx->component(VoteWidget::class, 'cats');
$dogs = $ctx->component(VoteWidget::class, 'dogs');
$parrots = $ctx->component(VoteWidget::class, 'parrots');
// cats_votes, dogs_votes, parrots_votes — independent signal IDs
$ctx->view('page.html.twig', [
'cats' => $cats(),
'dogs' => $dogs(),
'parrots' => $parrots(),
]);
}
Signals and scope
#[Signal] declares a reactive property that is synced to the browser and injected
into Twig. The scope is the attribute's first argument and defaults to Scope::TAB:
use Mbolli\PhpVia\Attributes\Signal;
use Mbolli\PhpVia\Scope;
#[Signal] // TAB (default) — private per tab, client-writable
public string $query = '';
#[Signal(Scope::ROUTE)] // shared across all users on this route
public int $sharedCounter = 0;
#[Signal(Scope::SESSION)] // shared across all tabs of one browser session
public string $username = 'Anonymous';
#[Signal(Scope::GLOBAL)] // shared across every connected user
public int $totalVisitors = 0;
TAB signals are client-writable via data-bind. Non-TAB scopes are
server-authoritative — the client cannot overwrite them — and they auto-broadcast to every
context in the scope whenever an action changes the value. No manual
$ctx->broadcast() is needed for scoped-signal changes.
Server-only state
#[Persist] marks a server-only instance property. No signal is created; the value
never reaches the browser. Because the class instance lives for the entire tab connection, the
value persists across all action calls. Use it for accumulators, flags, or route params stored
in view().
use Mbolli\PhpVia\Attributes\Persist;
#[Persist]
public int $multiplier = 1;
#[Action]
public function increment(Context $ctx): void {
$this->count += $this->multiplier; // grows +1, +2, +3… across clicks
++$this->multiplier; // survives between actions, invisible to client
}
Plain static class properties also work without any attribute — the framework
ignores them. They are process-global (shared across every instance and tab). After mutating
static data in an action, call $ctx->broadcast() to push a fresh render (see below).
Primary scope with #[Broadcast]
#[Broadcast(Scope::X)] on the class sets the context's primary scope — the target
of $ctx->broadcast() when called with no argument. Use it when an action mutates
non-signal state (a static array, a database row) and needs to push a re-render to everyone in
the scope:
use Mbolli\PhpVia\Attributes\Broadcast;
use Mbolli\PhpVia\Scope;
#[Broadcast(Scope::ROUTE)]
final class TodoList {
#[Signal] // still TAB-scoped — unaffected by #[Broadcast]
public string $draft = '';
/** @var array<int, array{text: string, done: bool}> */
private static array $todos = [];
public function view(Context $ctx): void {
$ctx->view('todo.html.twig', ['todos' => self::$todos], cacheUpdates: false);
}
#[Action]
public function add(Context $ctx): void {
if ($this->draft !== '') {
self::$todos[] = ['text' => $this->draft, 'done' => false];
$this->draft = '';
$ctx->broadcast(); // re-renders the list for everyone on this route
}
}
}
Important: #[Broadcast] only controls the broadcast target. It does
not change the scope of #[Signal] properties — each signal keeps the scope
it declares. A #[Signal] with no scope stays TAB-private even on a
#[Broadcast(Scope::ROUTE)] class.
Actions
#[Action] marks a public method as a client-callable action. The method name
becomes the URL slug as-is (saveName → /_action/saveName). Use the
optional name: parameter to override it, and scope: to register a
scoped action:
#[Action]
public function increment(Context $ctx): void { } // /_action/increment
#[Action(name: 'reset-tab')]
public function resetTab(Context $ctx): void { } // /_action/reset-tab
#[Action(scope: Scope::SESSION)]
public function setUsername(Context $ctx): void { } // session-scoped action
Each action method receives exactly one parameter: the Context for the current
tab. The framework re-hydrates all reactive properties from their current signal values before
calling the method, and syncs changed values back to signals afterwards.
Lifecycle hooks
Annotate a method with #[OnDisconnect] or #[OnCleanup] to run cleanup
when the connection closes. The method receives the Context. At most one method per
class may carry each attribute. Handlers are not re-hydrated first — they typically broadcast
presence changes or release resources rather than read live signals:
use Mbolli\PhpVia\Attributes\OnDisconnect;
#[OnDisconnect]
public function leave(Context $ctx): void {
--Room::$members[$this->room];
$ctx->broadcast();
}
Dependency injection
Via::mount() accepts an optional third argument: a factory callable invoked once
per client connection. Use it to inject services that the zero-arg constructor cannot reach:
// Without factory — zero-arg constructor used:
$app->mount(Counter::class, '/counter');
// With factory — called once per connecting tab:
$app->mount(
ChatPage::class,
'/chat/{room}',
fn(): ChatPage => new ChatPage($db, $mailer, $app)
);
Route parameters
Declare route parameters as typed arguments on view() after Context $ctx.
The framework injects them automatically by name, using the same type-casting logic as
$app->page(). Store them on a #[Persist] property to make them
available in all action methods:
class BlogPost {
#[Signal]
public int $likes = 0;
#[Persist]
public string $slug = '';
public function view(Context $ctx, string $slug): void {
$this->slug = $slug; // stored once, available in every action
$ctx->view('blog-post.html.twig');
}
#[Action]
public function like(Context $ctx): void {
++$this->likes;
// $this->slug is available here from view()
}
}
$app->mount(BlogPost::class, '/blog/{slug}');
Twig templates
Composition classes produce exactly the same named signals and actions as the closure API.
Template syntax is identical — {{ count.id }}, {{ increment.url }},
data-bind="{{ step.id }}". No template changes are needed when converting a
closure-based page to a class.
Actions registered via #[Action] are injected under their resolved name
(the name: parameter if set, otherwise the method name). The method is
converted to camelCase in the template variable — saveName is available as
{{ saveName.url }}.
Next steps
- Live demo — CompositionDemo + VoteWidget, all reactive shapes
- Components — the closure-based component API
- Scopes — TAB, ROUTE, SESSION, GLOBAL, and custom
- API: mount() — full method signature
- API: Attributes — attribute class reference