Documentation menu

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 attributeDeclaresBrowser can write itA change reaches
#[Signal]a TAB signalyesthis tab
#[Signal(Scope::ROUTE)]a ROUTE signalnoevery tab on the route
#[Signal(Scope::SESSION)]a SESSION signalnothe session's tabs
#[Signal(Scope::GLOBAL)]a GLOBAL signalnoevery tab
#[Persist]a server-only propertynono 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