Documentation menu

API Reference

Every public method of php-via 0.14 that an app calls, with its signature. Methods and classes tagged @internal in the source are left out: php-via calls them itself, and they change without notice. Config, Signal, Action and Scope are final, and Via and Context are not meant to be extended. The changelog maps every name 0.14 renamed or removed to its replacement.

All classes live under Mbolli\PhpVia\.

Via

The application: routes, the OpenSwoole server, broadcasts and the state every tab can reach. Via::VERSION holds the version string that /_health reports.

Construction

__construct(Config $config)

Takes the configuration and freezes it: a with* call on that Config afterwards throws a LogicException. It throws for a template setup that cannot work, such as withTemplateDir() without twig/twig installed.

use Mbolli\PhpVia\Config;
use Mbolli\PhpVia\Via;

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

getConfig(): Config

The frozen Config the app was built with. Its getters are listed under Config.

Routes

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

Registers a page. The handler gets the tab's Context and each path parameter as an argument of the same name, cast to the parameter's type. A parameter matches one path segment and arrives percent-decoded: /users/caf%C3%A9 gives café. An encoded slash stays inside its parameter and arrives as /, so /files/a%2Fb gives a/b: check a parameter you build a file path from. The handler runs once per page load, and again when a tab that was away is rebuilt (see Revival).

$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]);
});

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

RouteDefinition::middleware(MiddlewareInterface ...$middleware): RouteDefinition adds PSR-15 middleware to this route only. See Middleware.

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

Registers a class as a page. The class has a public view(Context $ctx): void method and declares its state and actions with the attributes. The factory, when given, builds the instance for each tab instead of new $class(), so the class can take constructor dependencies. See Composition.

$app->mount(Counter::class, '/counter');
$app->mount(ChatPage::class, '/chat/{room}', fn (): ChatPage => new ChatPage($db, $app));

route(array|string $methods, string $path, RequestHandlerInterface $handler): RouteDefinition

Registers a plain HTTP route for a PSR-15 handler, with no context, shell or template: a JSON endpoint, a webhook, an MCP server. The request passes the global middleware and the route's own, and the handler's response goes out as it is; a body of unknown size is sent as it is read.

  • $methods is one method, a list such as ['GET', 'POST', 'OPTIONS'], or '*' for every method the path has no route of its own for. A HEAD reaches a GET route as GET and goes out without the body. Other methods get 405 on a path without a page.
  • The request carries each path parameter as an attribute of its name, the session id in via.session, and uploaded files in getUploadedFiles().
  • php-via checks no Origin header here: add CSRF or auth middleware where a route changes state. The response sets no session cookie unless the handler or a middleware calls regenerateSession().
  • A throw from the handler, a middleware or the body answers 500, or closes a response that has started, and reaches onError() as ErrorPhase::Route.
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;

$app->route(['GET', 'POST'], '/api/items/{id}', new class implements RequestHandlerInterface {
    public function handle(ServerRequestInterface $request): ResponseInterface {
        return new Response(200, ['Content-Type' => 'application/json'], json_encode([
            'id' => $request->getAttribute('id'),
        ]));
    }
});

group(callable|string $prefixOrFn, ?callable $fn = null): RouteGroup

Registers the routes the closure registers, with an optional URL prefix for every page() and route() inside it. RouteGroup::middleware(MiddlewareInterface ...$middleware): RouteGroup adds middleware to all of them.

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

notFound(callable $handler): Via

Handles requests no route and no static file matched. The handler gets the raw OpenSwoole Request and Response, sets the status and ends the response. A Twig template rendered there through getTwig() can use the site's layout: via_head() and via_foot() write the import map and the Datastar script.

$app->notFound(function (Request $request, Response $response) use ($app): void {
    $response->status(404);
    $response->header('Content-Type', 'text/html; charset=utf-8');
    $response->end($app->getTwig()->render('404.html.twig'));
});

middleware(MiddlewareInterface ...$middleware): void

Registers global PSR-15 middleware. It runs on page loads, actions and route() requests, and on SSE connects when it implements SseAwareMiddleware. The request carries the visitor's session id in via.session. Middleware instances live as long as the worker, so keep per-request state in request attributes. See Middleware.

Server and workers

start(): void

Starts the OpenSwoole server. Call it last, after every route. It throws before it binds the port for settings that cannot work, such as a worker_num in withSwooleSettings() that differs from withWorkerNum(), or hook_flags with SWOOLE_HOOK_STDIO and without SWOOLE_HOOK_FILE.

onWorkerStart(callable $callback): void

Runs the callback in every worker as it starts, with the worker's id: four times with withWorkerNum(4), and again when a worker restarts after a reload, a max_request recycle or a crash. Run work meant for one worker where $workerId === 0: worker 0 always exists and keeps its id across restarts. Routes registered here are picked up.

onWorkerStop(callable $callback): void

Runs the callback in every worker as it stops, with the worker's id: on SIGTERM or SIGINT, and also on a reload (SIGUSR1) or a max_request recycle, which a callback cannot tell apart. The worker first asks its tabs to reconnect, ends their streams and waits up to half the stop budget for them and for spawn() tasks; then the callbacks run, each in its own try/catch. OpenSwoole's max_wait_time (3 seconds by default, counted in whole seconds) bounds the whole stop. End long-lived coroutines, sockets and Event::add fds here, or let loops check isShuttingDown(): anything still alive at the end is killed.

use OpenSwoole\Timer;

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

isShuttingDown(): bool

Whether this worker has begun to stop, reload or recycle. A background loop checks it to end before max_wait_time runs out.

setInterval(callable $callback, int $ms, bool $everyWorker = false): void

A server timer that fires every $ms milliseconds, armed when the worker starts and cleared when it stops. With several workers it runs on worker 0 only, so it fires once per interval for the whole server; everyWorker: true arms it in each worker. That governs the timer, not the state it touches: a PHP static it changes is still per worker. A throw is logged, reaches onError() as ErrorPhase::Timer, and the timer keeps running.

$app->setInterval(function () use ($app): void {
    if ($app->countClients(Scope::GLOBAL) > 0) {
        $app->broadcast(Scope::GLOBAL);
    }
}, 5000);

static defaultHookFlags(): int and static noFileIoHookFlags(): int

defaultHookFlags() returns the hook_flags php-via sets: SWOOLE_HOOK_ALL, without SWOOLE_HOOK_NATIVE_CURL when libcurl is 8.20 or newer, where OpenSwoole's native curl hook crashes the worker. noFileIoHookFlags() returns the socket, stream, sleep and proc_open() hooks, plus the native curl hook where libcurl is older than 8.20. Without the file and stdio hooks, file I/O blocks the worker for the call instead of going through the thread pool: use it only in apps that run no exec(), system() or popen() and hold no flock() across a suspension. See Deployment → Coroutine hooks.

$config->withSwooleSettings(['hook_flags' => Via::noFileIoHookFlags()]);

Clients and errors

onClientConnect(callable $callback): void

onClientDisconnect(callable $callback): void

Run when a tab opens or closes its SSE stream, with the tab's Context. On disconnect the tab has already left getClients(). A disconnect also fires for a short network blip; onCleanup() runs when the tab is gone for good. See Lifecycle.

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

onError(callable $callback): void

Sees each throw php-via catches from app code, for an error tracker, a metric or an error message on the tab. The callback gets the throwable, the context or null, an ErrorPhase and, for an action or a route, the action id or the route's path as registered. It only observes: php-via still logs the throw, a failing action still answers 500, and the signals the action changed still go to the tab, the ones the callback writes included. Callbacks run in the coroutine that caught the throw, before the action answers, so send a slow report from a coroutine of its own (OpenSwoole\Coroutine::create()). A throw inside a callback, or in a coroutine it starts, is only logged. Throws from lifecycle callbacks such as onCleanup() and php-via's own failures are only logged too.

use Mbolli\PhpVia\ErrorPhase;

$app->onError(function (\Throwable $e, ?Context $c, ErrorPhase $phase, ?string $action) use ($tracker): void {
    $tracker->capture($e, ['phase' => $phase->value, 'action' => $action]);
    $c?->getSignal('error')?->setValue('Something went wrong. Try again.');
});

getClients(): array

The connected tabs on every worker, keyed by context id, each with id, identicon, connected_at, ip and context_id. A broadcast reads the list once for all the views it renders.

countClients(string $scope): int

How many tabs with an open stream a broadcast of $scope reaches, on every worker: whether anyone is watching. A tab counts from its SSE connect until its stream closes, so a page that has not connected yet is left out. Scope::GLOBAL counts every connected tab, and a wildcard such as room:* counts each matching tab once. Pass a resolved scope: Scope::routeScope('/path'), not Scope::ROUTE.

getLocalContexts(string $scope): array

The contexts of this worker in $scope, connected or not. With several workers the contexts on other workers are missing, so an empty list does not mean the scope is empty: use countClients() for that.

Broadcasting

broadcast(string $scope): void

Re-renders every tab in a scope and publishes the scope to the other workers and nodes. Pass a resolved scope: Scope::GLOBAL, Scope::routeScope('/path'), Scope::sessionScope($id), a custom scope such as room:lobby, or a wildcard such as room:*. A bare Scope::TAB, Scope::ROUTE or Scope::SESSION throws an InvalidArgumentException, since outside a context there is nothing to resolve it against.

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

Inside a coroutine (actions, timers, SSE hooks, broker messages) the call only marks the scope. The worker's next flush renders it once and publishes it once, however often it was broadcast, so views show the state as it is then. That flush runs at the end of the current event-loop turn when the last flush started at least one broadcast tick ago (25 ms by default) and ended at least half a tick ago. A scope with a withBroadcastThrottle() renders at most once per its interval. Outside a coroutine and during shutdown the call renders before it returns. See Broadcasting → When the update is sent.

flushBroadcasts(): void

Renders the broadcasts marked so far now, in the calling coroutine, ignoring the tick and any throttle. Use it when the fan-out has to reach the tabs before something queued after it, or before shared state changes back. When another flush is rendering a scope this coroutine broadcast, it waits for that fan-out, at most one second.

$app->broadcast('room:lobby');
$app->flushBroadcasts();
$c->execScript('scrollToBottom()'); // arrives after the new messages

getScopedSignalByName(string $scope, string $name, ?string $namespace = null): ?Signal

Finds a scoped signal from code outside a context, such as a timer, by the name it was declared with. Pass a resolved scope, and for a signal declared inside a component the component's namespace. It returns null when no worker declared the signal. On a worker where no context declared it but another worker did, it returns a handle on the shared value, whose writes always broadcast the scope.

$app->setInterval(function () use ($app): void {
    $app->getScopedSignalByName('room:lobby', 'topic')?->setValue('Lunch');
}, 5000);

Global state

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

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

Server-side values every tab can read, for the life of the server or across restarts with withPersistentGlobalState(). With several workers, or with persistence, they live in shared memory: keys are at most 63 characters, and values are serialized copies. setGlobalState() is last-write-wins.

incrementGlobalState(string $key, int $by = 1): int

mutateGlobalState(string $key, callable $mutator): mixed

Read-modify-write without lost updates. setGlobalState($k, globalState($k) + 1) is two steps, and with several workers two of them read the same value: over 4 workers doing 500 increments each it kept 772 to 1238 of 2000. incrementGlobalState() is atomic, returns the new value and throws a LogicException when the key holds a non-integer. mutateGlobalState() passes the current value (null if unset) to the callback under a lock on the key and stores what it returns, so keep the callback short.

$app->incrementGlobalState('visits');
$app->mutateGlobalState('messages', fn (?array $msgs) => array_slice([...$msgs ?? [], $msg], -50));

Sessions

getSessionData(string $sessionId, string $key, mixed $default = null): mixed

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

clearSessionData(string $sessionId, ?string $key = null): void

Session data from outside a context, such as in middleware or a route() handler. The session id is the via.session request attribute or $c->getSessionId(). The rules are those of the context's methods.

regenerateSession(ServerRequestInterface $request): void

$c->regenerateSession() for middleware and route() handlers: the session rotates at the call, and the response to $request sets the new cookie. Middleware calls it before $handler->handle(); afterwards it throws a LogicException, and so it does for a request php-via did not hand over. It throws an OverflowException when the record of rotated cookies is full, so call it before the login writes anything.

$app->route('POST', '/login', new class($app, $users) implements RequestHandlerInterface {
    public function __construct(private Via $app, private Users $users) {}

    public function handle(ServerRequestInterface $request): ResponseInterface {
        $form = (array) $request->getParsedBody();
        $user = $this->users->check((string) ($form['email'] ?? ''), (string) ($form['password'] ?? ''));
        if ($user === null) {
            return new Response(303, ['Location' => '/login?failed=1']);
        }
        $this->app->regenerateSession($request);
        $this->app->setSessionData($request->getAttribute('via.session'), 'user', $user->id);

        return new Response(303, ['Location' => '/dashboard']);
    }
});

Markup on every page

appendToHead(string ...$elements): void

appendToFoot(string ...$elements): void

Add markup to every page: to <head> and before </body>. A shell writes them at {{ head_content }} and {{ foot_content }}. A view that renders a whole <html> document gets them before its first </head> and last </body>, on the page load and on every update, unless the exact markup is already there. $c->appendToHead() does the same for one page.

getTwig(): Environment

The Twig\Environment of the app's TwigEngine, for extensions, globals, runtime loaders and templates held as strings. It throws a LogicException when the app has no template engine or another engine than Twig.

$app->getTwig()->addExtension(new MyExtension());
$html = $app->getTwig()->createTemplate('<p>{{ greeting }}</p>')->render(['greeting' => 'Hello']);

Logs and statistics

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

Writes to php-via's log at debug, info, warn or error, filtered by withLogLevel(). The Dev Bar's Logs panel shows these lines.

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

getStats(): Stats

The server's statistics. getStats()->getStats() returns this worker's render timings (render_count, total_time, min_time, max_time, avg_time), and getStats()->getBroadcastStats() its broadcast flushes (scheduled, coalesced, flushes, flush_overruns and their durations in milliseconds). getStats()->getAll() returns every figure in one array: requests, avg_request_time (ms), actions and sse_connections count for the whole server, active_sse and active_contexts are this worker's open streams and contexts, and the render, GC and broadcast figures are this worker's. The Dev Bar's Stats panel shows them, and in dev mode GET /_stats serves the render and broadcast figures as JSON. getStats()->toPrometheus(['worker' => '0']) returns them in the Prometheus text format, the labels added to this worker's figures only.

metricsHandler(): RequestHandlerInterface

A handler for route() that serves toPrometheus() plus this worker's memory (via_memory_bytes, via_memory_peak_bytes) as a Prometheus scrape target. Counters are named via_*_total; durations are in seconds. Each scrape lands on one worker, so with worker_num above 1 the per-worker figures describe whichever worker answered. The route has no authentication: bind the server to 127.0.0.1 or block the path in the reverse proxy.

$app->route('GET', '/metrics', $app->metricsHandler());

Context

One context per browser tab, passed to the page handler, to actions and to views. A component gets a context of its own; getPageContext() leads from it to the page's.

Signals and actions

signal(mixed $initialValue, string $name, ?string $scope = null, bool $autoBroadcast = true, ?bool $clientWritable = null, bool $clientSeeded = false): Signal

Declares a signal: a value the server and the browser both hold. The name is required and unique per context; an empty name throws. Without a scope the signal is private to the tab. With a scope (Scope::ROUTE, Scope::SESSION, Scope::GLOBAL or a custom scope) every context that declares the same name in that scope shares one value, and the signal joins its context to the scope, so writes from other tabs reach this one. Scope::ROUTE is this route's scope, one per route pattern, and Scope::SESSION this session's. The primary scope set by scope() is not a default: after scope() set a shared scope, a signal without a scope throws a LogicException.

$count  = $c->signal(0, 'count');                    // private to the tab
$shared = $c->signal(0, 'count', Scope::ROUTE);      // shared by every tab on this route
$theme  = $c->signal('light', 'theme', Scope::SESSION);
$topic  = $c->signal('', 'topic', 'room:lobby');     // custom scope

$note   = $c->signal('', 'note', Scope::ROUTE, clientWritable: true); // tabs may write it
$status = $c->signal('idle', 'status', clientWritable: false);        // the server owns it
  • $autoBroadcast: a scoped signal broadcasts its scope when a write changes it. With false, call broadcast() or syncSignals() yourself.
  • $clientWritable: Datastar posts every signal the browser holds with each action, and php-via writes the values into the signals before the action runs. null lets TAB signals take them and keeps scoped ones server-owned; true or false applies to any scope. withStrictTabSignals() makes TAB signals server-owned by default. A value must have the type of the initial value: a lossless form such as '5' for an int or 'false' for a bool is converted, and any other value is refused. A signal declared with null takes any type.
  • $clientSeeded: the browser holds the initial value, such as one the page's own script reads from the URL, and $initialValue is only the fallback. The page seed and the first sync leave the signal out, and every SSE connect gives it the browser's value before the view renders, until the server writes it. Declare the value on <html> or in <head> before via_head. TAB signals only.

Declaring a TAB signal again with the same name returns the existing signal and sets it to the new initial value, with a warning when that changes the live value. Each signal has a browser id that is unique per tab; write it with id(), bind() and ref(), never by hand. A server-owned TAB signal lives on the worker that holds the tab: when that worker stops or crashes, it starts over from its initial value, so keep a value that must survive in a scoped signal or in tabState().

getSignal(string $name): ?Signal

The signal declared under $name in this context, TAB or scoped, or null.

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

Registers an action, which runs for the tab, or the component, that posts to its URL. The callable gets the Context. After the action, the signals it changed go to the tab, also when it throws, so a trailing syncSignals() is not needed; a scoped signal declared with autoBroadcast: false still needs one. Call sync() when the view has to render again. Registering a name twice keeps the later callback and logs a warning. A third argument throws an ArgumentCountError: a scoped action is a class method with #[Action(scope: ...)].

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

// In Twig: data-on:click="@post('{{ increment.url }}')"

Each action reads its own request through input(), file(), cookie() and getRequestAttribute(), also while another action of the tab runs. Datastar 1.0.4 cancels a request still in flight when any element sends another one to the same URL: see Actions → Cancellation.

getAction(string $name): ?Action

The action registered under $name, or null. Unnamed actions are not found.

Views and templates

view(callable|string $view, callable|array $data = [], ?string $block = null, bool $shareRender = false, bool|string $viewTransition = false): void

Defines what the tab shows: a callable that returns HTML, or a template name rendered by the template engine. A template gets the context's named signals and actions as variables, plus $data, which wins. $data can be a callable that runs on every render, for views that read state from elsewhere; an array is built once, when the page handler runs. $block sends only that block on updates, while the page load renders the whole template. $data and $block go with a template name only, and a string of HTML throws: return it from a callable.

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

$c->view('counter.html.twig');                          // count and increment come in by name
$c->view('todo.html.twig', ['todos' => $todos]);        // built once
$c->view('presence.html.twig', fn (): array => ['n' => $app->countClients(Scope::routeScope('/'))]);
$c->view('page.html.twig', fn (): array => ['items' => $repo->all()], block: 'list');

shareRender: true renders each update once for every tab of this view (same primary scope, route and component) instead of once per tab. Use it only for views that are the same for every tab: a TAB signal, per-user data or a component inside the view makes the shared HTML wrong for the others. It needs a shared primary scope from scope() and throws on a TAB one, and a view that renders a whole document never shares its render. See Views.

$c->scope(Scope::routeScope('/board'));
$c->view('board.html.twig', fn (): array => ['cells' => $board->cells()], shareRender: true);

viewTransition applies each update inside a view transition: true for the whole document, or a CSS selector for a transition scoped to that element. The page load and the render a stream sends when it connects have none. An empty selector or one with a line break throws an InvalidArgumentException. See View transitions.

render(string $template, array $data = [], ?string $block = null): string

Renders a template with the data a template view gets: the named signals and actions, _via, contextId, currentRoute, basePath, via_head and via_foot, then $data, which wins. Use it inside a view callable to combine templates and components. It throws a LogicException without a template engine.

$c->view(fn (): string => $c->render('page.html.twig', ['cart' => $cart()]));

viaHead(): string

viaFoot(): string

The tags that connect a page to php-via. viaHead() goes right after <meta charset>: the via_ctx signal, the import map when there is one, the SSE connect with its reconnect, and the beacon that closes the context when the tab goes. viaFoot() goes before </body>: the Datastar module script from getDatastarUrl(). Every tag carries the nonce from the page request's via.csp_nonce attribute. A shell writes them with {{ via_head }} and {{ via_foot }}, a Twig layout with {{ via_head() }} and {{ via_foot() }}, and a callable that returns a whole document with these methods. php-via warns once per shell or route without via_head. See Templates → The page bootstrap.

$c->view(fn (): string => '<!DOCTYPE html><html><head><meta charset="UTF-8">' . $c->viaHead()
    . '<title>Report</title></head><body><main id="report">...</main>' . $c->viaFoot() . '</body></html>');

setShellTemplate(string $path): Context

Uses another shell for this page than the one from withShellTemplate().

appendToHead(string ...$elements): Context

appendToFoot(string ...$elements): Context

Add markup to this page's <head> and before its </body>, as $app->appendToHead() does for every page.

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

Sending to the browser

sync(): void

syncSignals(): void

sync() renders the view and sends it with the changed signals; syncSignals() sends only the changed signals. While the tab has no open stream, both queue until it connects. Both do nothing once the context is destroyed.

patchElements(string $html = '', ?string $selector = null, PatchMode $mode = PatchMode::Outer, bool|string $viewTransition = false): void

Sends HTML to the page outside a view render, such as a modal, a toast or a chunk of streamed output. Without a selector, Outer and Replace match the top-level elements of $html by id; every other mode needs a selector, and Remove needs no HTML. Patches queue until the stream is open, and none is dropped for a client that falls behind, since no render sends it again. A component's patches go to its page. A selector with a line break throws an InvalidArgumentException, since it would end the SSE line: check a selector built from input. viewTransition applies the patch inside a view transition, as on view().

use Mbolli\PhpVia\PatchMode;

$c->patchElements('<li>' . htmlspecialchars($line) . '</li>', '#log', PatchMode::Append);
$c->patchElements(selector: '#toast', mode: PatchMode::Remove);

execScript(string $script): void

Runs JavaScript in the tab. Like a patch, the script queues until the stream is open and is never dropped. Under a Content-Security-Policy it needs the setup in Templates → Content-Security-Policy.

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

dispatch(string $event, mixed $detail = null): void

Fires a CustomEvent on the browser's window, with $detail JSON-encoded as its detail, for toasts and the like. The name and the detail cannot break out of the script, which a script built by hand for execScript() has to see to itself. Listen with data-on:toast__window. An empty name, or a detail json_encode() cannot encode, throws an InvalidArgumentException.

$c->dispatch('toast', ['level' => 'error', 'text' => 'Save failed']);
<div data-on:toast__window="$toast = evt.detail.text" data-text="$toast"></div>

download(callable|string $source, string $filename, string $mimeType): string

Returns a one-shot URL that sends a file as a download over plain HTTP, for exports that do not belong in the SSE stream. A string is the path of a file, which php-via does not delete. A callable returns the content, or yields it in chunks, when the browser fetches the URL, so a generator streams an export of any size. The URL works once, for this tab's session, while the context lives; a tab keeps its newest 100 URLs. A source that throws reaches onError() as ErrorPhase::Render, and the browser sees the download fail.

$c->action(function (Context $c) use ($rows): void {
    $url = $c->download(function () use ($rows): \Generator {
        foreach ($rows as $row) {
            yield implode(',', $row) . "\n";
        }
    }, 'flows.csv', 'text/csv; charset=utf-8');
    $c->execScript('window.location = ' . json_encode($url));
}, 'export');

isConnected(): bool

Whether the tab's SSE stream is open on this worker. A component answers for its page. Syncs and patches sent meanwhile queue for the next connect, so checking first only saves the render.

Scopes and broadcasting

scope(string $scope): void

Sets the primary scope: the target of broadcast() and the key of a shared render. It replaces the primary scope set before and keeps the scopes joined with addScope() or a scoped signal. Scope::ROUTE becomes this route's scope and Scope::SESSION this session's. It is no default for later declarations: signals and actions stay per tab unless they name a scope.

addScope(string $scope): void

removeScope(string $scope): void

Join or leave a scope, so the tab does or no longer receives that scope's broadcasts. Scope::ROUTE and Scope::SESSION resolve as in scope(). A tab always keeps its TAB scope.

$c->scope(Scope::ROUTE);           // primary scope: every tab on this route
$c->addScope('room:' . $roomId);   // also receives broadcasts to this room

broadcast(): void

Broadcasts this context's primary scope, with the timing of $app->broadcast(). With the default primary scope, TAB, it syncs this tab only: reach the scopes the tab joined with $app->broadcast().

Components

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

Creates a component: a context of its own inside the page, with its own signals, actions and view. Pass a setup callable or a class name for the composition API. The namespace prefixes the component's signals and actions, is unique on its page and takes letters, digits, _ and - only; anything else, or a namespace already on the page, throws an InvalidArgumentException. The returned callable renders the component: call it inside the page's view callable, so each page render carries the component's current HTML.

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

$cart = $c->component(Cart::class, 'cart');

$c->view(fn (): string => $c->render('page.html.twig', ['counter' => $counter(), 'cart' => $cart()]));

getPageContext(): Context

The page a component sits on, or the page itself. Use it to tell which tab an action inside a component came from.

getNamespace(): ?string

A component's namespace, or null for a page.

The request

These methods read the request the running code serves. In an action, in the renders it runs and in the coroutines it starts, that is the action's request, so two actions of one tab that run at once each read their own. In a spawn() task it is the request that started the task. Anywhere else, such as the page handler, a timer or a broadcast render, it is the page request. See Actions → Each action reads its own request.

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

A parameter of the request: POST first, then the query string. On a page load it reads the page's query. A context rebuilt after its tab was away, or on another worker, sees up to 512 bytes of the page's query again; keep longer state in a path parameter, a signal or tabState(). Use it instead of $_GET and $_POST, which are not safe under OpenSwoole.

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

file(string $name): ?array

An uploaded file of the action's multipart request: name, type, tmp_name, error and size. It is null when the field is missing or failed, outside an action, and once the action has answered, since OpenSwoole then deletes the temporary file: move the file before a spawn() task works on it. Post the form with Datastar's contentType: 'form'.

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

Read a cookie of the request, queue one for its response, or expire one. A cookie set in the page handler goes out with the page and one set in an action with that action's response, whatever its status. One set outside a request, such as in a timer, waits for the tab's next action response; a cookie cannot go out mid-stream.

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

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

getRequestAttributes(): array

Request attributes PSR-15 middleware set with $request->withAttribute(). In an action they come from that action's request, where only global middleware runs; anywhere else from the page request, behind the global and the route's middleware. See Middleware.

getPathParam(string $name): string

A path parameter of the page's URL by name, percent-decoded, or ''.

getRoute(): string

The route pattern the page was registered with, such as /users/{id}, the same for every URL it matches.

getId(): string

The context's id, which a revival keeps.

getConfig(): Config

The app's frozen Config.

Session

getSessionId(): ?string

The session id that all tabs of one browser share: a hash of the session cookie, not the cookie, so it survives regenerateSession(). Middleware reads the same id from the via.session request attribute. Keep data you store elsewhere under the user's id, not under the session id: the same user gets another session in another browser or once the cookie expires.

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

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

clearSessionData(?string $key = null): void

Server-side data of the session, shared by its tabs. It survives page reloads, the context and regenerateSession(), and is gone after a restart. With several workers it lives in shared memory: values must be serializable (setSessionData() throws an InvalidArgumentException otherwise), an object comes back as a copy, and one session holds 16 KB by default, past which it throws an OverflowException; see withSessionTableSize(). Each key is last-write-wins.

$c->setSessionData('auth', ['user' => 'ada', 'role' => 'Engineer']);
$auth = $c->sessionData('auth');   // the array or null
$c->clearSessionData('auth');      // one key
$c->clearSessionData();            // everything

regenerateSession(): void

Gives the session a new cookie with the response to the current action or page load. Call it at login, so a cookie someone planted or read before stops reaching the session, and at logout together with clearSessionData(). The session keeps its id, its data, its SESSION signals and its tabs on every worker. Call it after slow work such as a password check and before the login writes anything: it throws an OverflowException when the record of rotated cookies is full, and the visitor then stays logged out.

The rotation happens at the call. From then on, a page load, a route() request or a download with the old cookie gets a new session. For 2 more seconds the old cookie still reaches the actions and streams of tabs that exist, for requests other tabs sent before the browser had the new one; after that the streams opened with it end. A tab whose browser holds the new cookie reconnects at once; one with only the old cookie is refused. In an action the new cookie goes out with that action's response, in a page handler with the page, and called outside both, the rotation waits for the tab's next action.

The record of retired cookies lives in memory, four rows per session of withSessionTableSize(). When it is full, sessions without session data that were seen least recently lose their rows, and their retired cookies reach them again. After a restart the first cookie of a rotated session names that session again. Behind a load balancer, route by the balancer's own cookie, as the deployment guide does, since the session cookie changes.

$c->action(function (Context $c) use ($email, $password, $users): void {
    $user = $users->check($email->string(), $password->string());
    if ($user === null) {
        return;
    }
    $c->regenerateSession();
    $c->setSessionData('user', $user->id);
}, 'login');

Tab state

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

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

Server-side values of one tab that a revival brings back, such as the last query result or a cursor. They never reach the browser. They live as long as the context and then for the revival window of withContextTimeouts(). A component has keys of its own, values are copies, and null removes a key. Values must be serializable; a closure or a resource throws an InvalidArgumentException.

One worker keeps them in memory, and the revival records of destroyed tabs hold up to 64 MiB of them. With several workers every worker reads them from the shared context directory, up to 1024 serialized bytes per tab, past which setTabState() throws an OverflowException: raise it with withContextDirectorySize(4096, maxTabStateBytes: ...), where 4096 is the default row count. A write from a spawn() task or an onCleanup() callback after the context was destroyed still reaches the revived tab.

$results = $c->tabState('results', []);
$c->action(function (Context $c) use ($search): void {
    $c->setTabState('results', $search->run($c->input('q', '')));
    $c->sync();
}, 'search');

Timers, tasks and cleanup

onCleanup(callable $callback): void

Runs the callback, with the context, when the context is destroyed: its stream stayed closed for the cleanup delay, the tab sent its close beacon, or no stream connected in time. See Lifecycle.

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

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

A timer that ends with the context; it returns the timer id. A throw is logged, reaches onError() as ErrorPhase::Timer, and the timer keeps running.

$c->setInterval(function () use ($clock, $c): void {
    $clock->setValue(date('H:i:s'));
    $c->syncSignals();
}, 1000);

spawn(callable $task): void

Runs a task, which gets the context, in a coroutine of its own, for work that outlives the action that starts it, such as a long query that reports its progress. Call sync() or syncSignals() to send what it changes: only an action sends its changed signals by itself. A throw reaches onError() as ErrorPhase::Task. A task keeps running when its context is destroyed; from then on isDestroyed() is true and its syncs, patches, scripts and downloads do nothing. A stopping worker waits for its tasks within the stop budget.

use OpenSwoole\Coroutine;

$c->action(function (Context $c) use ($progress): void {
    $c->spawn(function (Context $c) use ($progress): void {
        for ($i = 1; $i <= 10; ++$i) {
            Coroutine::usleep(200_000); // stands for a slice of the work
            if ($c->isDestroyed()) {
                return;
            }
            $progress->setValue($i * 10);
            $c->syncSignals();
        }
    });
}, 'import');

isDestroyed(): bool

Whether the tab is gone for good and the onCleanup() callbacks ran. A revival builds a new context under the same id, so this one stays destroyed.

Dev Bar spans

span(string $name, callable $fn, array $attributes = []): mixed

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

span() times $fn as a span in the Dev Bar's current trace and returns what it returns; the prefix before the first dot picks its colour. traceAttribute() annotates the open span. Both cost nothing with the Dev Bar off. See Dev Bar.

$issues = $c->span('db.list_issues', fn () => $repo->all(), ['limit' => 50]);
$c->traceAttribute('cache.hit', false);

Signal

Returned by $c->signal().

Reading

getValue(): mixed

string(): string, int(): int, float(): float, bool(): bool, array(): array

The value, raw or cast. bool() reads the strings 'true', '1', 'yes' and 'on' as true in any case, and any other string as false. A value that is not a string follows PHP's truthiness, so 2 is true.

Writing

setValue(mixed $value): void

Sets the value and queues it for the next sync. A scoped signal also broadcasts its scope when the value changed, unless it was declared with autoBroadcast: false. A second argument throws an ArgumentCountError.

increment(int $by = 1): int

Adds to an integer signal atomically across workers and returns the new value; it throws a LogicException when the signal holds no integer. Use it for counters: setValue($s->int() + 1) loses updates when two workers read the same value.

mutate(callable $mutator): mixed

Reads, transforms and writes the value as one step under a lock, for read-modify-write on a non-integer scoped signal, and returns the value written. Keep the callback short: it holds up every other writer of the signal. Calls on one key run in order within a worker and take turns across workers; one waits up to 2 s behind its own worker and up to 5 s for its turn, then throws a RuntimeException. Pick increment() or mutate() per signal, since increment() skips the lock.

$items->mutate(fn (array $list) => [...$list, $item]);

markSynced(): void

Drops the signal's pending patch, so neither the page seed nor the next sync sends the current value. It does not stop a scoped signal's broadcast. For a value the browser seeds itself, declare the signal with clientSeeded: true instead.

In templates

id(): string

bind(?string $prop = null): string

ref(): string

id() is the browser id, not the name. bind() returns the data-bind attribute; with a property name it binds that element property instead, as data-bind__prop.value, the form for web components. A camelCase property is written in kebab case, and a name that is no property name throws an InvalidArgumentException. ref() is $ plus the id, for Datastar expressions.

$s->id();           // 'global_count' for a GLOBAL signal 'count'
$s->bind();         // 'data-bind="global_count"'
$s->bind('value');  // 'data-bind__prop.value="global_count"'
$s->ref();          // '$global_count', as in data-text="{$s->ref()}"

In Twig: {{ bind(volume) }}, {{ bind(volume, 'value') }} and ${{ volume.id }}.

Action

Returned by $c->action().

id(): string

url(): string

The action's id and the URL to post to. A component's action id starts with its namespace and a dash.

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

Scope

Scope names and the helpers that build resolved scopes. See Scopes.

Scope::TAB      // 'tab': one tab, the default
Scope::ROUTE    // 'route': in a context, every tab on this route pattern
Scope::SESSION  // 'session': in a context, every tab of this browser session
Scope::GLOBAL   // 'global': every tab on every route

static build(string ...$parts): string

Joins the parts with :: Scope::build('room', $id) gives room:42.

static routeScope(string $route): string

The scope of a route pattern, route:/path: what Scope::ROUTE resolves to on that route.

static sessionScope(string $sessionId): string

The scope of one browser session, what Scope::SESSION resolves to inside a context, for $app->broadcast() outside one. Pass the id from getSessionId() or via.session. It is derived from the id but never contains it, and its format may change, so build it only with this method.

PatchMode

A string enum of Datastar's patch modes, for patchElements().

  • PatchMode::Outer: morph the target element (the default)
  • PatchMode::Inner: morph the target's children
  • PatchMode::Replace: replace the target without morphing
  • PatchMode::Prepend and PatchMode::Append: insert as the target's first or last child
  • PatchMode::Before and PatchMode::After: insert before or after the target
  • PatchMode::Remove: remove the target

ErrorPhase

A string enum that tells an onError() callback where a throw came from.

  • ErrorPhase::Action: an action, or something it called, such as a sync() whose view threw. The context is the tab's page, also for a component's action.
  • ErrorPhase::Render: a page handler or view on page load or revival, a view on a stream's first sync or in a broadcast, or a download() source.
  • ErrorPhase::Timer: a $c->setInterval() or $app->setInterval() callback.
  • ErrorPhase::Task: a spawn() task.
  • ErrorPhase::Route: a route() handler or its middleware, with no context.

Config

Every setting of the app. The with* methods return the Config for chaining. new Via($config) freezes it, and a with* call afterwards throws a LogicException, so make every call before. clone $config gives a new Config that is not frozen, also when the original is.

$config = (new Config())
    ->withPort(3000)
    ->withDevMode(getenv('APP_ENV') === 'dev')
    ->withTemplateDir(__DIR__ . '/templates')
    ->withStaticDir(__DIR__ . '/public')
    ->withSecureCookie();

$app = new Via($config);

Server

withHost(string $host): Config

withPort(int $port): Config

The address and port the server listens on: 0.0.0.0 and 3000 by default.

withWorkerNum(int $n): Config

The number of OpenSwoole worker processes, 1 by default. With more than one, broadcasts cross workers through SwooleBroker unless withBroker() names another broker, and session data, global state and scoped signal values move to shared-memory tables. A tab lives on the worker that holds its stream, and an action or a download that reaches another worker is passed there. See Deployment → Same machine.

withSwooleSettings(array $settings): Config

Raw OpenSwoole server settings, merged over the ones php-via sets. hook_flags defaults to Via::defaultHookFlags(). Set the worker count with withWorkerNum(): start() throws for a worker_num here that differs from it. The thread pool limits (aio_*) are no server settings: set them with OpenSwoole\Util::setAio().

withCertificate(string $certFile, string $keyFile): Config

isHttps(): bool

Serve HTTPS and HTTP/2 directly with a PEM certificate and key; isHttps() tells whether both are set.

withH2c(bool $enabled = true): Config

Cleartext HTTP/2 behind a proxy that terminates TLS, such as Caddy's reverse_proxy h2c://localhost:3000. Never on a port exposed to untrusted traffic.

withBrotli(bool $enabled = true, int $dynamicLevel = 4, ?int $staticLevel = null): Config

$enabled turns on Brotli for pages and the SSE stream at $dynamicLevel. That needs ext-brotli and withCertificate() or withH2c(), and start() throws without them. Each busy stream's encoder grows toward about 9 MB at the default level, so pick level 1 when the number of connections matters more than bandwidth. See Deployment → Brotli.

Static files get Brotli at level 11 whenever ext-brotli is loaded, also without a call: compressible withStaticDir() files, /datastar.js, /via.css and the Dev Bar assets, compressed before the server listens or by a helper process, never in a worker. A foo.css.br next to foo.css, at least as new, is sent as it is, even without the extension. $staticLevel sets the level, 0 for none; left out it is 11, or 0 after withBrotli(false). withBrotli(false, staticLevel: 11) keeps it for static files only. See Deployment → Static compression.

Dev mode and logging

withDevMode(bool $devMode = true): Config

isDevMode(): bool

Dev mode turns on the Dev Bar and the default shell's Live Signals panel, shows an exception's class and message on a failed page instead of "Internal Server Error", serves static files with no-cache, reads an edited shell again, accepts actions without an Origin header, serves /_stats, and logs extra warnings. Off by default.

withLogLevel(string $level): Config

The lowest level logged: debug, info (the default), warn or error, in any case. The PSR-3 names (warning, notice, critical, alert, emergency) and the syslog short names (err, crit, emerg) work too; any other name throws an InvalidArgumentException.

withDevBar(bool $enabled): Config

Turns the Dev Bar on or off. Without the call it is on in dev mode only. true turns it on outside dev mode, read-only: every visitor then sees the traces, scopes, context ids and signal values of all tabs on the worker.

withDevBarOptions(?bool $writes = null, ?int $traces = null, ?int $pollMs = null): Config

Tunes the Dev Bar; null keeps a setting, so a second call changes only what it names. $writes lets the Signals panel write values back, in dev mode only. $traces is the number of traces kept per worker (100), and $pollMs how often its stream checks for new records (100 ms).

Templates and pages

withTemplateEngine(TemplateEngine $engine): Config

The engine that renders template views and render(). Without one, views are callables that return HTML, and a template view throws. See Template engines.

use Mbolli\PhpVia\Twig\TwigEngine;

$config->withTemplateEngine(new TwigEngine(__DIR__ . '/templates', cacheDir: '/tmp/twig'));

withTemplateDir(string $dir): Config

withTwigCacheDir(string $dir): Config

withTemplateDir() is short for withTemplateEngine(new TwigEngine($dir)), with compiled templates in the directory from withTwigCacheDir(), or in memory without it. It needs composer require twig/twig; new Via() throws without it.

withShellTemplate(string $path): Config

A plain HTML shell around every view that is not a whole document. See Templates → Shells.

withBasePath(string $basePath): Config

getBasePath(): string

The URL prefix when the app is mounted at a sub-path, such as /myapp. It must be a relative path; an absolute or protocol-relative URL throws an InvalidArgumentException. Templates get it as {{ basePath }} and shells as {{ base_path }}. See Deployment → Sub-path mounting.

Static files

withStaticDir(string $dir): Config

Serves the files in $dir: a path with a file extension before routing, any other path once no route matched, so /about serves the route of that name. Files are served from worker memory and read again when they change. Paths with a dot segment (except /.well-known/), PHP sources such as .php, .phtml and .inc, and links to them answer 404. See Deployment → Static assets.

withStaticCacheControl(Closure|string|null $value): Config

The Cache-Control of static files, /datastar.js and /via.css, which all answer conditional requests with 304. null (the default) is no-cache in dev mode and public, max-age=3600, must-revalidate otherwise, and a year with immutable for /datastar.js?v=. A string applies to every file. A closure gets the file's path and MIME type and returns a value, or null to keep the default for that file.

$config->withStaticCacheControl(function (string $filePath, string $mimeType): ?string {
    if (preg_match('/\.[0-9a-f]{8,}\./', basename($filePath)) || str_starts_with($mimeType, 'font/')) {
        return 'public, max-age=31536000, immutable';
    }

    return null;
});

Datastar and modules

withDatastarRocket(bool $enabled = true): Config

Serves Starbase's build of Datastar 1.0.4 with Rocket at /datastar.js instead of the plain Datastar 1.0.4 bundle, so Rocket web components work on the page: 22 KB with Brotli against 12 KB. See Web components.

getDatastarUrl(): string

The URL pages load Datastar from: the base path, datastar.js and the first 10 hex digits of the served bundle's SHA-256, such as /datastar.js?v=727844adfc. A new bundle gets a new URL, so browsers fetch it after an upgrade. via_foot loads it.

getDatastarIntegrity(): string

The served bundle's Subresource Integrity value (sha384), for apps that pin Datastar. php-via does not pin it by itself. Pin it after withDatastarRocket() and withBasePath(), which change the URL; new Via() warns about a pin for another URL of the bundle. It throws a RuntimeException when the bundle cannot be read.

$config->withImportMap([], [$config->getDatastarUrl() => $config->getDatastarIntegrity()]);

withImportMap(array $imports, array $integrity = []): Config

getImportMap(): array

Add entries to the import map that via_head writes: $imports maps module specifiers to URLs, $integrity module URLs to Subresource Integrity values. Calls merge, and a later entry for the same key wins. A URL is absolute or starts with /, a specifier ending in / needs a URL ending in /, and an integrity value is sha256-, sha384- or sha512- with the base64 digest; anything else throws an InvalidArgumentException, and so does an entry for datastar, which php-via maps to getDatastarUrl() itself. The map is written when this added entries or withDatastarRocket() is on. getImportMap() returns it, datastar first. See Web components.

SSE and broadcasts

withSseKeepAliveMs(int $ms): Config

How long a stream may stay silent before it sends an SSE comment, 15 s by default, so a proxy's idle timeout does not cut it. 0 sends none.

withSseMaxQueuedBytes(int $bytes): Config

The unsent backlog, 1 MiB by default, past which a stream drops view updates for a slow client instead of waiting for it; the next render sends them again. Once past it, the stream drops them until its backlog is empty. Signals, scripts and patchElements() patches are never dropped. 0 turns dropping off. A threshold above half of socket_buffer_size (2 MiB by default) acts as half of it, and start() logs a warning that names the buffer size to set with withSwooleSettings().

withBroadcastTickMs(int $ms): Config

The minimum gap between the starts of two broadcast flushes of a worker, 25 ms by default. An idle server flushes at the end of the event-loop turn; under load every broadcast in between shares the next flush. 0 flushes every turn. See Broadcasting → When the update is sent.

withBroadcastThrottle(string $scope, int $minIntervalMs): Config

Renders the broadcasts of a scope at most once per interval, for a scope that changes more often than its tabs need to see, such as the progress of an import. A broadcast within the interval waits for it to end and the ones meanwhile join it, so the last state always arrives. A wildcard such as import:* gives each matching scope an interval of its own, and $app->flushBroadcasts() renders a waiting broadcast at once. 0 removes the throttle; a bare Scope::TAB, ROUTE or SESSION throws.

$config->withBroadcastThrottle('import:*', 250);

withBroadcastCoalescing(bool $enabled = true): Config

Deprecated, removed in 0.15. false renders and publishes on every broadcast() call, and new Via() logs a warning. Call $app->flushBroadcasts() where a broadcast has to land before the next step.

Contexts

withContextTimeouts(?int $cleanupDelayMs = null, ?int $connectMs = null, ?int $reconnectMs = null, ?int $revivalWindowMs = null, ?int $forwardMs = null): Config

How long a context lives without an SSE stream. A timer left out keeps its value. See Lifecycle.

  • $cleanupDelayMs (5 s): after a stream closes, the context waits this long for a reconnect before it is destroyed. 0 destroys it at once.
  • $connectMs (30 s): a page load whose stream never connects, such as a crawler's, is destroyed after this long. 0 keeps it until the worker stops.
  • $reconnectMs (60 s): an action that reaches a context whose stream is down keeps it this long, so the action's patches reach the tab when it reconnects. It must exceed Datastar's longest wait between retries, 30 s by default. 0 uses $connectMs.
  • $revivalWindowMs (10 min): for this long after a context is destroyed, a returning tab gets an equivalent one (same id, handler run again, signals from the browser, tabState() back) instead of a reload. 0 turns revival off.
  • $forwardMs (60 s): with several workers, the worker that passed an action or a download to the tab's worker answers 504 when no answer came this long after the request. At least 1 s.

getContextRevivalWindowMs(): int

The revival window in milliseconds, 0 when revival is off.

withGcIntervalMs(int $ms, bool $onGrowth = false): Config

How a worker runs PHP's cycle collector, whose every run walks all live objects. By default PHP runs it itself, and each worker also runs it every $ms (30 s); 0 turns the timed runs off. onGrowth: true turns PHP's own runs off: the worker then runs the collector when its memory has grown by half since the last run, and every $ms while possible roots wait. With onGrowth, a loop that creates cycles without ever waiting on I/O frees them only once it ends: call gc_collect_cycles() in such a loop. Performance → Cycle collector gives what each choice costs.

Security

Names the session cookie __Host-via_session_id with the Secure flag, for any site served over HTTPS. A plain via_session_id cookie is then ignored.

withEmbeddable(array|string|null $frameAncestors = null, bool $partitioned = true): Config

Makes the app work in a cross-origin <iframe>: the session cookie gets SameSite=None; Secure and, by default, Partitioned, and given origins become a Content-Security-Policy: frame-ancestors header on pages. It implies withSecureCookie() and needs HTTPS or withH2c(); start() throws otherwise. See Deployment → Embedding in a cross-origin iframe.

withTrustedOrigins(?array $origins): Config

The Origin values an action request may carry, such as https://example.com. With null (the default) the origin's host must match the Host header.

withAllowMissingOrigin(bool $allow = true): Config

Accepts action requests without an Origin header outside dev mode, for non-browser clients such as curl. Off by default: they get 403. It also applies to the Dev Bar's write endpoints.

withStrictTabSignals(bool $strict = true): Config

Makes TAB signals server-owned unless declared clientWritable: true. Check which signals the page binds or assigns in the browser before you turn it on.

withActionRateLimit(int $maxRequests, int $windowSeconds = 60): Config

At most $maxRequests actions per IP per window, then 429. 0 is no limit, the default.

Brokers and shared state

withBroker(MessageBroker $broker): Config

onBrokerError(callable $handler): Config

The broker that carries broadcasts to other workers and nodes. One worker uses InMemoryBroker and several SwooleBroker without the call; RedisBroker and NatsBroker reach other servers. Passing InMemoryBroker with more than one worker makes start() throw. onBrokerError() gets the Throwable each time the broker loses its connection. See Multi-node Broker.

withGlobalStateTableSize(int $maxRows, int $maxValueBytes = 32768): Config

withScopedSignalTableSize(int $maxRows, int $maxValueBytes = 32768): Config

The shared-memory tables behind global state and scoped signal values with several workers: rows and bytes per serialized value, 1024 rows by default. The row count is a floor, not a ceiling, and integers ignore the byte cap. A value past the byte cap throws an OverflowException, and so does a GlobalState write to a full table. A scoped signal write to a full table is dropped and logged as an error. See Deployment.

withContextDirectorySize(int $maxRows, int $maxRecordBytes = 1024, int $ttlSeconds = 3600, int $maxTabStateBytes = 1024): Config

The shared directory that tells every worker where a tab lives: one row per live or recently destroyed context (4096 by default), so size it for the peak number of tabs. A tab that finds no room loses cross-worker reachability, and a warning is logged. $maxTabStateBytes caps a tab's tabState() values; each KB of it costs about 2 KB × $maxRows of memory from start-up.

withSessionTableSize(int $maxSessions, int $maxBytesPerSession = 16384): Config

The shared-memory table behind session data with several workers: 1024 sessions of 16 KB by default. Past $maxSessions the least recently used sessions are dropped with a warning. It reserves about 2 × $maxSessions × $maxBytesPerSession of shared memory. The record of rotated cookies has 4 × $maxSessions rows.

withPersistentGlobalState(string $path, int $flushMs = 1000): Config

Keeps global state in a SQLite file across restarts. Reads never touch SQLite: writes go to shared memory, and worker 0 writes the changed keys in one transaction every $flushMs, at about 1 µs per key. What was written since the last flush is lost if the process dies. Integers keep their type, so incrementGlobalState() works on a restored counter.

Template engines

php-via renders template views through a TemplateEngine. It ships one for Twig, which needs composer require twig/twig. See Templates.

Rendering\TemplateEngine

render(string $template, array $data, ?string $block = null): string

supportsBlocks(): bool

The interface an engine implements. render() gets the template name, the data a template view gets (see render()), and a block name only when supportsBlocks() is true. view(..., block:) with an engine that renders no blocks throws a LogicException.

Rendering\Html

__toString(): string

Trusted markup in the template data: via_html_attrs, via_head and via_foot arrive as Html. An engine prints it unescaped, so an autoescaping engine marks the class safe, as TwigEngine does. php-via builds these; apps only print them.

Twig\TwigEngine

__construct(string $templateDir, string|false $cacheDir = false)

render(string $template, array $data, ?string $block = null): string

supportsBlocks(): bool

environment(): Environment

Twig templates from $templateDir, compiled into $cacheDir or in memory. Templates are autoescaped as HTML and strict about undefined variables, and get the functions bind(), dump(), via_html_attrs(), via_head() and via_foot() and the basePath global. via_html_attrs() writes data-nonce for the page's <html> under a CSP nonce. environment() is the Twig\Environment, which $app->getTwig() returns too. The constructor throws a LogicException when twig/twig is not installed.

Testing

Testing\TestApp runs an app's pages in a test, with no server, no port and no VIA_TEST_MODE, through php-via's own request, action and SSE handlers. Middleware, session and origin checks, revival and the post-action signal send run as on a server. It does not simulate several workers, real broadcast timing, an event loop outside runTasks(), the network, or a browser past its signals: html() renders from server state, and actions post JSON.

use Mbolli\PhpVia\Testing\TestApp;

it('counts clicks', function (): void {
    $app = new TestApp(new Config(), function (Via $via): void {
        $via->page('/counter', function (Context $c): void {
            $count = $c->signal(0, 'count');
            $c->action(fn () => $count->setValue($count->int() + 1), 'increment');
            $c->view(fn (): string => "<p id=\"count\">{$count->int()}</p>");
        });
    });

    $tab = $app->open('/counter');
    $tab->action('increment')->action('increment');

    expect($tab->signal('count'))->toBe(2);
    $app->shutdown();
});

Testing\TestApp

__construct(Config $config, callable $routes)

Builds the app: new Via($config), then $routes with the Via, then the onWorkerStart callbacks as worker 0, and arms the setInterval() timers as worker 0 does.

open(string $path, array $query = [], bool $connect = true): TestTab

Loads a page in a new browser with a session of its own and, by default, opens its SSE stream. It throws a RuntimeException for a status other than 200.

request(string $method, string $path, string $body = '', array $headers = []): ResponseInterface

A plain HTTP request from a client without cookies, such as an API client calling a route(). A body sent as application/x-www-form-urlencoded reaches getParsedBody(), as on a server; a multipart body does not.

runTasks(float $timeoutSeconds = 5.0): void

Runs the event loop until the spawn() tasks have ended and their broadcasts have rendered. Timers fire meanwhile, $c->setInterval() and $app->setInterval() callbacks included: the constructor arms the latter as worker 0 does. With nothing pending it returns at once. A task otherwise runs only up to its first wait.

via(): Via

The app's Via, for broadcast(), globalState() and the like.

broadcasts(): array

The scopes broadcast since the last call, one entry per call.

logs(): array

The lines php-via logged since the last call, kept instead of printed.

shutdown(): void

Stops as a worker stops: streams end, the onWorkerStop callbacks run and timers are cleared.

Testing\TestTab

One tab of a TestApp. It holds the signal values a browser would and sends them with every action and connect. Name a component's signal or action as 'namespace.name'.

open(string $path, array $query = [], bool $connect = true): TestTab

Another tab of the same browser: same cookies, same session.

action(string $name, array $input = [], array $signals = []): TestTab

Posts an action with $input as the query string and the tab's signals, after $signals changed them as a bound input would. It throws a RuntimeException when the action answers with a status other than 200, as a throwing action answers 500.

request(string $method, string $path, string $body = '', array $headers = []): ResponseInterface

A plain HTTP request with this browser's cookies, such as a fetch of a download() URL.

patches(): array

The patches the stream sent since the last call. An element patch has html, selector and mode (a PatchMode); a signal patch has signals by name.

html(): string

The page as a page load renders it now, from server state.

signal(string $name): mixed

The value the browser holds for the signal, null when it holds none.

connect(): TestTab

disconnect(bool $expire = false): TestTab

Open or end the SSE stream. disconnect(expire: true) also lets the cleanup delay pass, so the context is destroyed, and the next connect() or action() revives it.

context(): Context

The tab's page context on the server. It throws a LogicException while the context is destroyed.

Attributes

PHP attributes for the composition API, under Mbolli\PhpVia\Attributes\.

#[Signal]

#[Signal(string $scope = Scope::TAB, bool $atomic = false, ?bool $clientWritable = null)]

On a property: a signal named after the property, with the property's default as its initial value. TAB signals are private to the tab and client-writable; shared scopes are server-owned and broadcast their scope when the value changes. clientWritable overrides that rule. Two components of one class get distinct signal ids through their namespaces.

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

#[Signal]                    // private to the tab
public int $count = 0;

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

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

atomic: true makes a shared integer property safe to change from several workers: the action's net change goes through Signal::increment(). Assignment then becomes an adjustment, so $this->votes = 0 subtracts the current value; set a value with $ctx->getSignal('votes')->setValue(0).

#[Persist]

On a property: server-only state of the tab's instance, kept across actions and never sent to the browser. A revival starts it over.

#[Broadcast]

#[Broadcast(string $scope)]

On a class: sets the context's primary scope, the target of $ctx->broadcast(), for actions that change state other than signals. It does not change the scope of the #[Signal] properties.

#[Broadcast(Scope::ROUTE)]
final class TodoList {
    private static array $todos = [];

    #[Signal]
    public string $draft = '';

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

    public function view(Context $ctx): void { /* ... */ }
}

#[Action]

#[Action(?string $name = null, ?string $scope = null)]

On a public method that takes Context $ctx: an action named after the method, or name:, used in its URL and as its template variable. With scope: one action serves the whole scope, and it still runs on the instance of the tab that posts it. A component's action URL starts with its namespace.

#[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 {}   // one action for the session's tabs

#[OnCleanup]

On a method that takes the Context: runs when the context is destroyed, as onCleanup() does. Several methods may carry it and run in declaration order, after the reactive properties are read back from their signals.

#[OnCleanup]
public function leave(Context $ctx): void {
    unset(self::$members[$this->room][$ctx->getId()]);
    $this->app->broadcast('room:' . $this->room); // $app comes in through mount()'s factory
}