Documentation menu

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:

AttributeKindClient-writableAuto-broadcasts
#[Signal]TAB signalyes
#[Signal(Scope::ROUTE)]ROUTE signalnoyes → same route
#[Signal(Scope::SESSION)]SESSION signalnoyes → user's tabs
#[Signal(Scope::GLOBAL)]GLOBAL signalnoyes → all users
#[Persist]server-only propertyno
#[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