Documentation menu

API Reference

Complete reference for the php-via public API. Methods marked @internal may change between versions.

Via

new Via(Config $config)

$app = new Via(
    (new Config())
        ->withHost('0.0.0.0')
        ->withPort(3000)
        ->withTemplateDir(__DIR__ . '/templates')
);

page(string $route, callable $handler): RouteDefinition

Register a page route. Path params use {name} syntax and are injected as typed arguments. Returns a RouteDefinition for fluent middleware registration.

$app->page('/', fn(Context $c) => $c->view('home.html.twig'));

$app->page('/users/{id}', function (Context $c, int $id): void {
    $c->view('user.html.twig', ['id' => $id]);
});

// With per-route middleware:
$app->page('/admin', fn(Context $c) => $c->view('admin.html.twig'))
    ->middleware(new AuthMiddleware());

mount(string $class, string $route, ?callable $factory = null): RouteDefinition

Register a class as a page using the composition API. The class must have a public view(Context $ctx): void method. Reactive properties are declared with #[Signal] (optionally scoped, e.g. #[Signal(Scope::SESSION)]); server-only state with #[Persist]; action methods with #[Action]. Returns a RouteDefinition for fluent middleware chaining. See Composition.

// Zero-arg constructor (default):
$app->mount(Counter::class, '/counter');

// Factory — called once per connecting tab, receives no arguments:
$app->mount(
    ChatPage::class,
    '/chat/{room}',
    fn(): ChatPage => new ChatPage($db, $app)
);

// Middleware chain:
$app->mount(Dashboard::class, '/dashboard')
    ->middleware(new AuthMiddleware());

middleware(MiddlewareInterface ...$middleware): void

Register global PSR-15 middleware that runs on every page and action request. See Middleware.

$app->middleware(new CorsMiddleware([...]));

broadcast(string $scope): void

Push updates to all contexts in a scope. Use after modifying global state outside an action.

$app->broadcast(Scope::GLOBAL);
$app->broadcast(Scope::routeScope('/'));
$app->broadcast('room:lobby');

globalState(string $key, mixed $default = null): mixed

setGlobalState(string $key, mixed $value): void

Shared in-memory state across all contexts. Persists for the server lifetime; cleared on restart.

$app->setGlobalState('counter', 0);
$count = $app->globalState('counter', 0);

onStart(callable $callback): void

onShutdown(callable $callback): void

Server lifecycle hooks. Use onStart to create timers; onShutdown to clear them.

$timerId = null;
$app->onStart(function () use ($app, &$timerId): void {
    $timerId = Timer::tick(5000, fn() => $app->broadcast(Scope::GLOBAL));
});
$app->onShutdown(function () use (&$timerId): void {
    if ($timerId !== null) Timer::clear($timerId);
});

setInterval(callable $callback, int $ms): void

Register a process-wide recurring timer that fires every $ms milliseconds. The timer is started automatically when the server starts and cleared on shutdown — no manual onStart/onShutdown wiring required.

// Broadcast global state every 5 seconds
$app->setInterval(fn() => $app->broadcast(Scope::GLOBAL), 5000);

group(callable $fn): RouteGroup

Register multiple routes inside a closure and attach shared middleware to all of them in one call. Returns a RouteGroup with a fluent middleware() method. Only routes registered inside the closure are included.

use Mbolli\PhpVia\Http\RouteGroup;

$app->group(function (Via $app): void {
    $app->page('/admin', fn(Context $c) => ...);
    $app->page('/admin/users', fn(Context $c) => ...);
})->middleware(new AuthMiddleware());

onClientConnect(callable $callback): void

onClientDisconnect(callable $callback): void

Fire when a client opens or closes an SSE connection. The callback receives the client's Context. On disconnect, the client has already been removed from getClients(). See Lifecycle for usage.

$app->onClientConnect(fn(Context $c) => $app->broadcast(Scope::routeScope($c->getRoute())));
$app->onClientDisconnect(fn(Context $c) => $app->broadcast(Scope::routeScope($c->getRoute())));

getClients(): array

$clients = $app->getClients();
// ['contextId' => ['id' => '...', 'ip' => '...', 'connected_at' => 1234567890]]
$count = count($app->getClients());

appendToHead(string ...$elements): void

$c->appendToHead(
    '<title>My Page</title>',
    '<meta name="description" content="...">'
);

getTwig(): Twig\Environment

$app->getTwig()->addExtension(new MyExtension());

log(string $level, string $message, ?Context $context = null): void

$app->log('info', 'Server ready');
$app->log('debug', 'Action fired', $c);

start(): void

Start the OpenSwoole server. Call this last, after all routes are registered.

---

Context

One context per connected browser tab. Holds signals, actions, and view definition for that client.

signal(mixed $initial, ?string $name, ?string $scope, bool $autoBroadcast, bool $clientWritable)

$count  = $c->signal(0);                            // TAB scope, auto-named
$count  = $c->signal(0, 'count');                   // named
$shared = $c->signal(0, 'count', Scope::ROUTE);     // shared across route
$pref   = $c->signal('light', 'theme', Scope::SESSION);
$room   = $c->signal('', 'note', 'room:lobby');     // custom scope

// Collaborative: allow clients to write to a scoped signal
$input  = $c->signal('', 'note', Scope::ROUTE, clientWritable: true);

Scoped signals are server-authoritative by default — clients cannot overwrite shared state. Use clientWritable: true for collaborative inputs where multiple clients need to push values (e.g. shared text fields with data-bind).

action(callable $fn, ?string $name = null): Action

$inc = $c->action(function (Context $c) use ($count): void {
    $count->setValue($count->int() + 1);
    $c->sync();
}, 'increment');

// In Twig: data-on:click="@post('/_action/increment')"

view(callable|string $view, array $data = [], ?string $block = null, bool $cacheUpdates = true): void

Signals and actions registered on the context are auto-injected into every Twig render as named variables (signal baseName, action camelCase name). Entries in $data take priority.

// count and increment are auto-injected — no explicit passing needed
$c->view('counter.html.twig');

// Only pass data that isn't a signal/action:
$c->view('todo.html.twig', ['todos' => $todos]);

$c->view(fn() => "<p>Count: {$count->int()}</p>");

// Send only {% block demo %} on SSE updates (full template on initial load):
$c->view(fn() => $c->render('todo.html.twig', ['todos' => $todos]), block: 'demo');

// Disable caching for views that read external state:
$c->view('presence.html.twig', ['n' => count($app->getClients())], cacheUpdates: false);

component(callable|string $fn, string $namespace): callable

Create a reusable sub-context. Pass a callable for the closure API or a class name string for the composition API — the class is analyzed once and its setup closure built automatically. See Components and Composition.

// Closure form:
$counter = $c->component(function (Context $c): void {
    $n   = $c->signal(0, 'n');
    $inc = $c->action(fn() => $n->setValue($n->int() + 1), 'inc');
    $c->view('counter.html.twig');
}, 'counter');

// Class form (composition API):
$counter = $c->component(Counter::class, 'counter');

$c->view('page.html.twig', ['counter' => $counter()]);

getSignal(string $name): ?Signal

getAction(string $name): ?Action

Retrieve a registered signal or action by name after registration. Useful in actions that need to look up signals by name rather than via a captured variable.

$c->getSignal('count');    // returns the Signal registered as 'count', or null
$c->getAction('increment'); // returns the Action registered as 'increment', or null

scope(string $scope): void

addScope(string $scope): void

$c->scope(Scope::ROUTE);          // set primary scope
$c->addScope('room:' . $roomId);  // add additional scope

sync(): void

syncSignals(): void

sync() re-renders the view and pushes signals. syncSignals() pushes only signals, skipping view re-render.

broadcast(): void

Broadcast to all contexts in this context's primary scope.

execScript(string $script): void

$c->execScript('document.querySelector(".flash").classList.add("show")');

setInterval(callable $callback, int $ms): int

Recurring timer, auto-cancelled when the context is destroyed.

$c->setInterval(function () use ($count, $c): void {
    $count->setValue($count->int() + 1);
    $c->sync();
}, 1000);

onDisconnect(callable $callback): void

onCleanup(callable $callback): void

Per-context disconnect hook for cleaning up resources. onCleanup is an alias. See Lifecycle.

$c->onDisconnect(function (Context $c) use ($room, $app): void {
    unset(Room::$members[$room][$c->getId()]);
    $app->broadcast('room:' . $room);
});

cookie(string $name): ?string

setCookie(string $name, string $value, int $expires = 0, string $path = '/', bool $httpOnly = true, bool $secure = true, string $sameSite = 'Lax'): void

deleteCookie(string $name, string $path = '/'): void

Coroutine-safe cookie access. cookie() reads a request cookie (replaces $_COOKIE, which is unsafe in OpenSwoole). setCookie() queues a cookie for the response with secure defaults. deleteCookie() expires a cookie immediately. Cookies queued during a page load are flushed by RequestHandler; cookies set inside an action are flushed by ActionHandler.

$theme = $c->cookie('theme');        // null if not set
$c->setCookie('theme', 'dark');      // Secure, HttpOnly, SameSite=Lax by default
$c->deleteCookie('theme');           // expires cookie immediately

file(string $name): ?array

Read a file from a multipart form upload. Returns the upload array (name, type, tmp_name, size) or null if the field is missing or errored. Use Datastar's contentType: 'form' modifier to submit the form as a real multipart POST.

$upload = $c->file('avatar');
if ($upload !== null) {
    move_uploaded_file($upload['tmp_name'], '/uploads/' . basename($upload['name']));
}

Getters

$c->getId();              // unique context ID
$c->getRoute();           // current route, e.g. '/chat/lobby'
$c->getSessionId();       // session ID shared across tabs
$c->getPathParam('id');   // path parameter by name
$c->getNamespace();       // component namespace, or null

sessionData(string $key, mixed $default = null): mixed

setSessionData(string $key, mixed $value): void

clearSessionData(?string $key = null): void

Per-session server-side storage, keyed on the session cookie. Survives page refreshes and context destruction — unlike signals, which reset on refresh.

$c->setSessionData('auth', ['user' => 'ada', 'role' => 'Engineer']);

$auth = $c->sessionData('auth');       // returns the array or null

$c->clearSessionData('auth');          // clear one key
$c->clearSessionData();                // clear entire session bucket

input(string $name, mixed $default = null): mixed

Read a query or POST parameter safely. Checks POST first, then query string. Replaces direct $_GET/$_POST access (which is coroutine-unsafe in OpenSwoole).

$id = $c->input('id');                  // POST['id'] ?? GET['id'] ?? null
$cat = $c->input('cat', 'all');         // with default

getRequestAttribute(string $name, mixed $default = null): mixed

Read a request attribute set by PSR-15 middleware. See Middleware.

$user = $c->getRequestAttribute('user');  // set by AuthMiddleware
---

Signal

// Reading
$s->getValue();   // raw mixed
$s->int();        // cast to int
$s->string();     // cast to string
$s->bool();       // cast to bool

// Writing
$s->setValue(42); // triggers broadcast if scoped

// Template helpers
$s->id();     // signal name, e.g. 'count'
$s->bind();   // 'data-bind="count"'
$s->text();   // '<span data-text="$count"></span>'
---

Action

$a->url();  // '/_action/increment'
$a->id();   // 'increment'
---

Scope

Scope::TAB      // default — per browser tab
Scope::ROUTE    // all clients on same URL
Scope::SESSION  // all tabs of same user
Scope::GLOBAL   // every connected client

Scope::routeScope('/path')  // explicit: 'route:/path'
---

Config

All methods return self for chaining.

(new Config())
    ->withHost('0.0.0.0')           // default: '0.0.0.0'
    ->withPort(3000)                // default: 3000
    ->withDevMode(true)             // enables debug logging
    ->withLogLevel('info')          // 'debug' | 'info' | 'error'
    ->withTemplateDir(__DIR__ . '/templates')
    ->withShellTemplate(__DIR__ . '/shell.html')
    ->withStaticDir(__DIR__ . '/public')
    ->withBasePath('/')             // base path when mounted at a sub-path (see below)
    ->withSsePollIntervalMs(50)     // SSE poll interval in ms
    ->withSwooleSettings([...])     // raw OpenSwoole server settings
    // Security
    ->withSecureCookie(true)        // __Host- prefix, Secure flag
    ->withTrustedOrigins(['https://example.com'])  // CSRF origin allowlist
    ->withEmbeddable('https://parent.example')  // SameSite=None; Secure; Partitioned + frame-ancestors
    ->withActionRateLimit(100, 60)  // max 100 actions per 60s per IP
    // HTTPS / HTTP2 / Brotli compression (requires ext-brotli)
    ->withCertificate('/path/to/cert.pem', '/path/to/key.pem') // direct TLS + HTTP/2
    ->withH2c()                     // h2c (cleartext HTTP/2) for proxy scenarios
    ->withBrotli()                  // enable Brotli; withBrotli(dynamicLevel: 4, staticLevel: 11)
    // Multi-worker (single machine)
    ->withWorkerNum(swoole_cpu_num())   // default: 1; requires SwooleBroker/RedisBroker/NatsBroker when > 1
    ->withGlobalStateTableSize(2048, 8192)  // GlobalState table: rows × value bytes (default 1024 × 4096)
    // Multi-node broker (optional — required only for multi-worker / multi-server deployments)
    ->withBroker(new SwooleBroker())    // same machine, no external deps
    ->withBroker(new RedisBroker('127.0.0.1', 6379))  // or new NatsBroker(...)
    ->onBrokerError(fn(\Throwable $e) => error_log($e->getMessage())) // called on reconnect

withBrotli() requires either withCertificate() (direct HTTPS) or withH2c() (proxy with Caddy/Nginx handling TLS). A hard error is thrown at start() if ext-brotli is missing or neither HTTPS mode is configured. See Deployment → Brotli for setup guides.

withEmbeddable(array|string|null $frameAncestors = null, bool $partitioned = true) makes the app safe to embed in a cross-origin <iframe>: it sets the session cookie to SameSite=None; Secure; Partitioned (so the browser sends it inside a cross-site frame, which the SSE session-auth gate needs) and emits Content-Security-Policy: frame-ancestors when given origins. It implies withSecureCookie(true) and requires HTTPS or withH2c(); start() hard-errors otherwise. It is unrelated to withTrustedOrigins(). See Deployment → Embedding in a cross-origin iframe.

withBasePath(string $basePath) sets the URL prefix when the app is mounted at a sub-path (e.g. example.com/myapp/). The value must be a relative path such as /, /myapp, or /sub/path. An \InvalidArgumentException is thrown at startup for invalid inputs (absolute URLs, protocol-relative paths, etc.). The basePath is exposed to every Twig template as /. See Deployment → Sub-path mounting for a Caddy example.

---

Attributes

PHP 8 attribute classes for the composition API. All live under Mbolli\PhpVia\Attributes\.

#[Signal(string $scope = Scope::TAB)]

Target: property. Creates a reactive signal backed by the property's default value, synced to the browser and auto-injected into Twig by name. The scope is the first argument:

  • Scope::TAB (default) — isolated per tab, client-writable via data-bind
  • Scope::ROUTE — shared across all users on the same route
  • Scope::SESSION — shared across all tabs of one browser session
  • Scope::GLOBAL — shared across every connected user
  • custom string — shared across all contexts in that scope (e.g. "room:lobby")

Non-TAB scopes are server-authoritative (the client cannot write them) and auto-broadcast to every context in the scope when the value changes. Component instances with the same property name get independent signal IDs when a namespace is set (e.g. cats_votes, dogs_votes).

use Mbolli\PhpVia\Attributes\Signal;
use Mbolli\PhpVia\Scope;

#[Signal]                       // TAB — private per tab, client-writable
public int $count = 0;

#[Signal(Scope::ROUTE)]         // shared across all users on this route
public int $sharedCounter = 0;

#[Signal(Scope::SESSION)]       // shared across the user's tabs
public string $username = 'Anonymous';

#[Signal(Scope::GLOBAL)]        // shared across all users
public int $totalClicks = 0;

#[Persist]

Target: property. Marks a plain server-only instance property. No signal is created; the value is never sent to the browser. Because the class instance lives for the tab connection lifetime, 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; // invisible to client, persists per tab

#[Broadcast(string $scope)]

Target: class. Sets the context's primary scope — the target of $ctx->broadcast() with no argument. Use it when an action mutates non-signal state (a static array, a DB row) and needs to re-render for everyone in the scope. It does not change the scope of #[Signal] properties — each signal keeps its own declared 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 = '';

    #[Action]
    public function add(Context $ctx): void {
        self::$todos[] = $this->draft;
        $ctx->broadcast();     // reaches every context on this route
    }
}

#[Action(name?: string, scope?: string)]

Target: method. Marks a public method as a client-callable action. The method must accept Context $ctx as its only parameter. The resolved name (method name by default, or name: if provided) is used as the action URL slug and Twig variable name. scope: is forwarded to Context::action().

use Mbolli\PhpVia\Attributes\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 setTheme(Context $ctx): void { }        // session-scoped action

#[OnDisconnect] / #[OnCleanup]

Target: method. Registers the method as a disconnect/cleanup handler (via Context::onDisconnect() / Context::onCleanup()). The method receives the Context. At most one method per class may carry each attribute. Handlers are not re-hydrated before running — they do cleanup rather than read live signals.

use Mbolli\PhpVia\Attributes\OnDisconnect;

#[OnDisconnect]
public function leave(Context $ctx): void {
    --Room::$members[$this->room];
    $ctx->broadcast();
}

Next steps

  • Signals — reactive state in depth
  • Actions — server-side event handlers
  • Scopes — sharing state across clients
  • Lifecycle — connect, disconnect, timers
  • Composition — class-based pages with attributes