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.
- Via: routes, the server, broadcasts, shared state and sessions
- Context: one per tab, with its signals, actions, view and request
- Signal, Action, Scope, PatchMode and ErrorPhase
- Config: every setting, frozen by
new Via() - Template engines:
TemplateEngine,HtmlandTwigEngine - Testing:
TestAppandTestTab - Attributes for class-based pages
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.
$methodsis 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 ingetUploadedFiles(). - php-via checks no
Originheader here: add CSRF or auth middleware where a route changes state. The response sets no session cookie unless the handler or a middleware callsregenerateSession(). - A throw from the handler, a middleware or the body answers 500, or closes a response that has
started, and reaches
onError()asErrorPhase::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 messagesgetScopedSignalByName(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. Withfalse, callbroadcast()orsyncSignals()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.nulllets TAB signals take them and keeps scoped ones server-owned;trueorfalseapplies 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 withnulltakes any type.$clientSeeded: the browser holds the initial value, such as one the page's own script reads from the URL, and$initialValueis 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>beforevia_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 roombroadcast(): 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 defaultfile(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']));
}cookie(string $name): ?string
setCookie(string $name, string $value, int $expires = 0, string $path = '/', string $domain = '', bool $secure = true, bool $httpOnly = true, string $sameSite = 'Lax'): void
deleteCookie(string $name, string $path = '/'): void
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(); // everythingregenerateSession(): 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 routestatic 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 childrenPatchMode::Replace: replace the target without morphingPatchMode::PrependandPatchMode::Append: insert as the target's first or last childPatchMode::BeforeandPatchMode::After: insert before or after the targetPatchMode::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 async()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 adownload()source.ErrorPhase::Timer: a$c->setInterval()or$app->setInterval()callback.ErrorPhase::Task: aspawn()task.ErrorPhase::Route: aroute()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
withSecureCookie(bool $secure = true): Config
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
}