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 viadata-bindScope::ROUTE— shared across all users on the same routeScope::SESSION— shared across all tabs of one browser sessionScope::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