Composition
The composition API lets you define reactive pages and components as plain PHP classes annotated with attributes, instead of writing closure-based setup code. php-via turns each class into the same page handler or component closure you would write by hand, so both styles mix on one page.
Properties carry the state. #[Signal] declares a reactive property, with its scope as a
parameter, and #[Persist] a server-only one:
| Property attribute | Declares | Browser can write it | A change reaches |
|---|---|---|---|
#[Signal] | a TAB signal | yes | this tab |
#[Signal(Scope::ROUTE)] | a ROUTE signal | no | every tab on the route |
#[Signal(Scope::SESSION)] | a SESSION signal | no | the session's tabs |
#[Signal(Scope::GLOBAL)] | a GLOBAL signal | no | every tab |
#[Persist] | a server-only property | no | no tab |
On the class, #[Broadcast(Scope::X)] sets the primary scope. On methods,
#[Action] marks an action and #[OnCleanup] a cleanup hook.
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.ref }}">{{ count.int }}</span></p>
<label>Step: <input type="number" {{ bind(step) }}></label>
<button data-on:click="@post('{{ increment.url }}')">+</button>
<button data-on:click="@post('{{ reset.url }}')">Reset</button>
</div>
view() may also define a closure view, as a page handler does; a template needs
twig/twig (see Views).
Class-based components
Pass a class name to $ctx->component() to use the same API for reusable sub-contexts.
Each mount needs its own namespace on the page, and gets signal ids and action URLs of its own:
public function view(Context $ctx): void {
$cats = $ctx->component(VoteWidget::class, 'cats');
$dogs = $ctx->component(VoteWidget::class, 'dogs');
$parrots = $ctx->component(VoteWidget::class, 'parrots');
// independent signal IDs: global_cats_votes____kn, global_dogs_votes____kn, ...
// rendered inside the view closure, so each page render carries the current votes
$ctx->view(fn (): string => $ctx->render('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 take the value the browser posts with each action, so data-bind works on them.
The value must fit the property's declared type: '5' from a text field is stored as
5 in an int property, and a value that does not convert is refused. Scoped
signals belong to the server: the browser cannot write them, and a change in an action broadcasts the
scope, so every tab in it renders again without a $ctx->broadcast().
clientWritable overrides the write rule in either direction:
#[Signal(clientWritable: false)] // TAB, but the browser cannot overwrite it
public string $status = 'idle';
#[Signal(Scope::ROUTE, clientWritable: true)] // shared input every client can write
public string $note = '';
With Config::withStrictTabSignals(), a TAB #[Signal] without the
argument is server-owned, so a property bound with data-bind needs
clientWritable: true. See Values the browser sends.
atomic: true makes a shared integer property safe to change from several workers at once.
The property is read before the action and written back after, so two workers running
++$this->votes together would both write back the same result. With
atomic: true the action's net change goes through Signal::increment()
instead. The catch: assignment becomes an adjustment, so $this->votes = 0 subtracts
the current value rather than resetting it. To set a value, use
$ctx->getSignal('votes')->setValue(0). Integer properties only.
#[Signal(Scope::GLOBAL, atomic: true)]
public int $votes = 0;Server-only state
#[Persist] marks a server-only instance property. No signal is created; the value
never reaches the browser. The instance lives as long as the tab's context, so the value persists
across all action calls. Use it for accumulators, flags, or route params stored in view().
A revival builds a new instance, which starts from the
property's default; keep what has to survive in $ctx->setTabState().
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 on the worker). After
changing static data in an action, broadcast the scope whose tabs show it, as below.
Primary scope with #[Broadcast]
#[Broadcast(Scope::X)] on the class sets the context's primary scope, the target
of $ctx->broadcast(). Use it when an action changes non-signal state (a static array, a
database row) and every tab in the scope has to render again:
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', fn (): array => ['todos' => self::$todos]);
}
#[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
}
}
}
#[Broadcast] only sets the broadcast target. Each #[Signal] keeps the scope
it declares, so a #[Signal] with no scope stays TAB-private on a
#[Broadcast(Scope::ROUTE)] class. Without #[Broadcast] the primary scope is
TAB, and $ctx->broadcast() renders the acting tab only.
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 one action
for a whole scope:
#[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 { } // one action for the session's tabs
Each action method receives exactly one parameter: the Context of the tab that posted
it. The framework re-hydrates all reactive properties from their current signal values before calling
the method, and writes changed values back to their signals afterwards, also when the method throws.
The changed TAB signals then go to the tab, as for any action.
Before each render, the scoped properties are hydrated too, so a view that reads
$this->total shows what another tab or worker wrote when its broadcast reaches this
tab. A render inside an action of the same instance, such as one from $ctx->sync(), shows
the action's own changes instead, which are written back when the method returns.
A scoped action runs on the instance of the tab that posts it, so one tab's click never changes another
tab's properties. A SESSION or custom scope is joined by the context that declares it. In a component,
its URL starts with the namespace, as a per-tab action's does (/_action/cats-vote), so two
mounts of one class keep their actions apart.
Lifecycle hooks
Annotate a method with #[OnCleanup] to run cleanup when the context is destroyed.
The method receives the Context. Several methods may carry the attribute; they run in
declaration order, after reactive properties are re-hydrated from their signals. A class that still uses
#[OnDisconnect] throws at mount() and component():
#[OnCleanup] runs at the same moment.
use Mbolli\PhpVia\Attributes\OnCleanup;
final class ChatPage {
#[Persist]
public string $room = '';
public function __construct(private Via $app) {}
// view(), signals and actions ...
#[OnCleanup]
public function leave(Context $ctx): void {
--Room::$members[$this->room];
$this->app->broadcast('room:' . $this->room);
}
}
The cleanup broadcasts the room's scope with $app->broadcast(): the context being
destroyed has no tab left to render, and on a class without #[Broadcast]
$ctx->broadcast() would reach only that tab. The factory below hands the class its
Via.
Dependency injection
Via::mount() accepts an optional third argument: a factory callable invoked once
per context. 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($app)
);Route parameters
Declare route parameters as typed arguments on view() after Context $ctx.
The framework injects them by name, percent-decoded and cast to the type, as for
$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.ref }}, {{ increment.url }},
{{ bind(step) }}. 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), in camelCase:
reset-tab is available as {{ resetTab.url }}, and
saveName 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