mbolli / php-via
Real-time engine for building reactive web applications in PHP
Requires
- php: ^8.4
- ext-openswoole: ^26.0
- nyholm/psr7: ^1.8
- openswoole/core: ^26.2
- psr/http-server-middleware: ^1.0
- starfederation/datastar-php: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.90.0
- openswoole/ide-helper: ^26.2
- pestphp/pest: ^5.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan: ^2.1.0
- phpstan/phpstan-deprecation-rules: ^2.0
- tomasvotruba/type-coverage: ^2.3
- twig/twig: ^3.10
Suggests
- ext-brotli: Brotli level 11 for static files as soon as it is loaded (compressed before the server listens), and for pages and SSE streams with Config::withBrotli()
- twig/twig: Twig templates for views, with Config::withTemplateDir() or withTemplateEngine(new Mbolli\PhpVia\Twig\TwigEngine(...))
Provides
None
Conflicts
- twig/twig: <3.10
Replaces
None
README
Real-time reactive web framework for PHP. Server-side reactive UIs with zero JavaScript, using OpenSwoole for async PHP, Datastar for SSE + DOM morphing, and optionally Twig for templates.
Documentation & Live Examples · Upgrading from 0.13? See Upgrading to 0.14.
Why php-via?
- No JavaScript to write: Datastar handles client-side reactivity, SSE, and DOM morphing
- Closures or Twig: views are closures that return HTML, or Twig templates once
twig/twigis installed - No build step: no transpilation, no bundling, no node_modules
- Real-time by default: every page gets a live SSE connection
- Scoped state: TAB, ROUTE, SESSION, GLOBAL, and custom scopes control who shares what
- Single SSE stream: extremely efficient with Brotli compression
- Web components: Rocket and Starbase components bound to your signals, with an opt-in Datastar build
Requirements
- PHP 8.4+
- OpenSwoole PHP extension 26+
- Composer
- Brotli PHP extension (optional: static files get Brotli level 11 once it is loaded, pages and SSE with
Config::withBrotli())
Installation
composer require mbolli/php-via
composer require twig/twig # for Twig templates, as in the quick start
Quick Start
<?php require 'vendor/autoload.php'; use Mbolli\PhpVia\Via; use Mbolli\PhpVia\Config; use Mbolli\PhpVia\Context; $config = new Config(); $config->withTemplateDir(__DIR__ . '/templates'); $app = new Via($config); // every with* call goes before this line $app->page('/', function (Context $c): void { $count = $c->signal(0, 'count'); $step = $c->signal(1, 'step'); $c->action(function () use ($count, $step): void { $count->setValue($count->int() + $step->int()); // sent to the tab after the action }, 'increment'); $c->view('counter.html.twig'); }); $app->start();
counter.html.twig:
<div id="counter"> <p>Count: <span data-text="{{ count.ref }}">{{ count.int }}</span></p> <label>Step: <input type="number" data-bind="{{ step.id }}"></label> <button data-on:click="@post('{{ increment.url }}')">Increment</button> </div>
php app.php
# → http://localhost:3000
Core Concepts
Full documentation at via.zweiundeins.gmbh/docs
Signals: reactive state that syncs between server and client
$name = $c->signal('Alice', 'name'); $name->string(); // read $name->setValue('Bob'); // write; an action sends it to the tab when it ends
<input data-bind="{{ name.id }}"> <span data-text="{{ name.ref }}">{{ name.string }}</span>
A signal is private to its tab unless you pass a scope: $c->signal(0, 'votes', Scope::ROUTE).
Actions: server-side functions triggered by client events
$save = $c->action(function () use ($c): void { $c->sync(); }, 'save');
<button data-on:click="@post('{{ save.url }}')">Save</button>
Important: Always trigger actions with
@post()(or@patch/@put/@delete).@get()is blocked on/_action/…: GET requests return 405 Method Not Allowed, because allowing actions over GET enables top-level cross-site navigation CSRF.
Scopes: control who shares state and receives broadcasts
| Scope | Sharing | Use Case |
|---|---|---|
Scope::TAB |
Isolated per tab (default) | Personal forms, settings |
Scope::ROUTE |
All users on same route | Shared boards, multiplayer |
Scope::SESSION |
All tabs in same session | Cross-tab state |
Scope::GLOBAL |
All users everywhere | Notifications, announcements |
Custom ("room:lobby") |
All contexts in that scope | Chat rooms, game lobbies |
$c->scope() sets the scope a context's view and $c->broadcast() belong to. Signals take their scope as an
argument, and a view shares its update render only with shareRender: true.
Views: closures or Twig templates
$c->view(fn (): string => "<main id=\"hello\">Hello {$name->string()}</main>"); $c->view('dashboard.html.twig', fn (): array => ['user' => $user]); // needs twig/twig $c->view('board.html.twig', fn (): array => ['cells' => $cells->array()], block: 'board', shareRender: true);
A string is always a template name. Pass the template data as a closure when it changes after the page load, and
shareRender: true only for a view that is the same for every tab in its scope.
Path Parameters: auto-injected by name
$app->page('/blog/{year}/{slug}', function (Context $c, string $year, string $slug): void { // ... });
Components: reusable sub-contexts with isolated state
$a = $c->component($counterWidget, 'a'); $b = $c->component($counterWidget, 'b');
Lifecycle Hooks
$c->onCleanup(fn () => $app->log('info', 'tab closed')); $c->setInterval(fn () => $c->sync(), 2000); // stops with the context $c->spawn(function (Context $c): void { // work that outlives the action $c->getSignal('report')?->setValue(buildReport()); $c->syncSignals(); }); $app->onClientConnect(fn (Context $c) => $app->broadcast('presence')); $app->onError(fn (\Throwable $e, ?Context $c, ErrorPhase $phase, ?string $action) => error_log("{$phase->value}: {$e->getMessage()}")); $app->setInterval(fn () => $app->broadcast(Scope::GLOBAL), 5000); // server-wide timer, on one worker
onCleanupfires after a grace period (default: 5 seconds) that tolerates page navigation and brief reconnects without tearing down state. Tune it withConfig::withContextTimeouts(cleanupDelayMs: ...).If a tab is backgrounded long enough that its context is destroyed, the returning tab revives: the server rebuilds an equivalent context (same ID) and re-seeds the signal values the client still holds, instead of hard-reloading and losing local signals, scroll, and focus. On by default (10-minute window); tune or disable with
Config::withContextTimeouts(revivalWindowMs: ...). Revival re-runs the page handler, so server-only#[Persist]state resets and lifecycle hooks re-fire, just as on a reload. Server-owned TAB signals (clientWritable: false, or all of them underConfig::withStrictTabSignals()) start from the handler's initial value, and values kept with$c->setTabState()come back. The route's middleware runs again on a revival, so an auth gate applies. WithwithWorkerNum()above 1 a tab lives on the worker of its SSE stream, and actions that reach another worker are passed there; when the stream reconnects to another worker, the context is rebuilt the same way and takes the TAB signal values the old worker still holds.
Route Groups: shared prefix and/or middleware
$app->group('/admin', function (Via $app): void { $app->page('/', fn(Context $c) => ...); // → /admin $app->page('/users', fn(Context $c) => ...); // → /admin/users })->middleware(new AuthMiddleware());
Broadcasting: push updates to other connected clients
$c->broadcast(); // the context's primary scope; only this tab when it is TAB $app->broadcast(Scope::GLOBAL); // all contexts $app->broadcast(Scope::routeScope('/board')); // one route $app->broadcast('room:lobby'); // custom scope $app->countClients('room:lobby'); // connected tabs it reaches, on every worker
Multi-node broadcasting: Redis and NATS brokers
One worker needs no broker. Several workers on one machine (withWorkerNum()) use SwooleBroker
unless you pass another, with no external service. To fan out broadcast() calls across multiple
servers or containers, swap in RedisBroker or NatsBroker:
use Mbolli\PhpVia\Broker\RedisBroker; use Mbolli\PhpVia\Broker\NatsBroker; // Redis (requires ext-redis and SWOOLE_HOOK_TCP, or SWOOLE_HOOK_TLS with tls: true; the default // hook_flags include both, see https://via.zweiundeins.gmbh/docs/deployment#hooks) $config->withBroker(new RedisBroker('127.0.0.1', 6379)); // Redis with auth and TLS $config->withBroker(new RedisBroker( host: 'redis.internal', password: $_ENV['REDIS_PASSWORD'], tls: true, )); // NATS (raw OpenSwoole socket, no extra extension) $config->withBroker(new NatsBroker('127.0.0.1', 4222)); // NATS with token auth and TLS $config->withBroker(new NatsBroker( host: 'nats.internal', authToken: $_ENV['NATS_TOKEN'], tls: true, )); // Error observability: called on every connection drop $config->onBrokerError(fn(\Throwable $e) => error_log('Broker: ' . $e->getMessage()));
Both brokers reconnect automatically with exponential backoff (1 s → 30 s cap).
A GET /_health endpoint is available on every php-via server (no configuration needed):
{"status":"ok","version":"0.14.2","broker":{"driver":"RedisBroker","connected":true},"connections":{"contexts":42,"sse":38}}
Returns HTTP 503 when the broker is in the reconnect backoff window.
Testing: TestApp runs your pages without a server
use Mbolli\PhpVia\Testing\TestApp; $app = new TestApp(new Config(), function (Via $via): void { $via->page('/', function (Context $c): void { $count = $c->signal(0, 'count'); $c->action(fn () => $count->setValue($count->int() + 1), 'increment'); $c->view(fn (): string => "<main id=\"counter\">{$count->int()}</main>"); }); }); $tab = $app->open('/'); $tab->action('increment'); assert($tab->signal('count') === 1); $app->shutdown();
How it Works
1. Browser requests page → Server renders HTML, opens SSE stream
2. User clicks button → Datastar POSTs signal values + action ID
3. Server executes action → Modifies signals / state
4. Server pushes patches → HTML fragments + signal updates via SSE
5. Datastar morphs DOM → UI updates without page reload
Development
git clone https://github.com/mbolli/php-via.git cd php-via && composer install # Start website + hot PHP reload + CSS watcher (requires entr) composer run dev # Run tests composer run test # Run tests with every real-server fixture on a port from 4350-4389 (default: a window per fixture) VIA_TEST_PORT_BASE=4350 VIA_TEST_PORT_COUNT=40 composer run test # Watch tests on file change (requires entr) composer run watch-test # Static analysis and code style composer phpstan composer cs-fix
Hot PHP reload: edit a file in website/src/ or a template, and the workers restart (~1 s) without dropping other connections. See docs/development for the full workflow and how to replicate this pattern in your own project.
Credits
- Datastar: SSE + DOM morphing
- OpenSwoole: Async PHP
- Twig: Templating, optional
- go-via/via: Original Go inspiration
License
MIT
