php-via Server-side reactive UIs in PHP
php-via keeps the state of each page on the server. You write signals, actions and views in PHP. Datastar applies the server's patches in the browser over one SSE stream per tab, and scopes decide who shares which state.
composer require mbolli/php-via
A complete counter app
The listing is a full app.php. The TAB version is the default:
every browser tab keeps its own count. The GLOBAL version passes
Scope::GLOBAL to signal(), and all visitors share the count. Composition shows the same counter as a
class with #[Signal] and #[Action] attributes, as described in the
composition docs.
1<?php
2require __DIR__ . '/vendor/autoload.php';
3
4use Mbolli\PhpVia\Config;
5use Mbolli\PhpVia\Context;
6use Mbolli\PhpVia\Via;
7
8$app = new Via(new Config());
9
10$app->page('/', function (Context $c): void {
11 // TAB scope (default): each visitor has their own counter
12 $count = $c->signal(0, 'count');
13
14 // The new value goes to this tab after the action
15 $increment = $c->action(function () use ($count): void {
16 $count->setValue($count->int() + 1);
17 });
18
19 $c->view(fn() => <<<HTML
20 <div id="counter">
21 <p>Count: <strong data-text="{$count->ref()}">
22 {$count->int()}
23 </strong></p>
24 <button data-on:click="@post('{$increment->url()}')">
25 +1
26 </button>
27 </div>
28 HTML);
29});
30
31$app->start(); 1<?php
2require __DIR__ . '/vendor/autoload.php';
3
4use Mbolli\PhpVia\Config;
5use Mbolli\PhpVia\Context;
6 +use Mbolli\PhpVia\Scope;
7use Mbolli\PhpVia\Via;
8
9$app = new Via(new Config());
10
11$app->page('/', function (Context $c): void {
12 - // TAB scope (default): each visitor has their own counter
13 - $count = $c->signal(0, 'count');
14 + // GLOBAL scope: one shared counter for every visitor
15 + $count = $c->signal(0, 'count', Scope::GLOBAL);
16
17 - // The new value goes to this tab after the action
18 - $increment = $c->action(function () use ($count): void {
19 - $count->setValue($count->int() + 1);
20 + // Atomic, and the write broadcasts to every tab in the scope
21 + $increment = $c->action(function () use ($count): void {
22 + $count->increment();
23 });
24
25 $c->view(fn() => <<<HTML
26 <div id="counter">
27 <p>Count: <strong data-text="{$count->ref()}">
28 {$count->int()}
29 </strong></p>
30 <button data-on:click="@post('{$increment->url()}')">
31 +1
32 </button>
33 </div>
34 HTML);
35});
36
37$app->start(); 1<?php
2require __DIR__ . '/vendor/autoload.php';
3
4use Mbolli\PhpVia\Attributes\Action;
5use Mbolli\PhpVia\Attributes\Signal;
6use Mbolli\PhpVia\Config;
7use Mbolli\PhpVia\Context;
8use Mbolli\PhpVia\Via;
9
10final class Counter {
11 // TAB scope (default): each visitor has their own counter
12 #[Signal]
13 public int $count = 0;
14
15 #[Action]
16 public function increment(): void {
17 $this->count++;
18 }
19
20 public function view(Context $c): void {
21 $count = $c->getSignal('count');
22 $increment = $c->getAction('increment');
23
24 $c->view(fn() => <<<HTML
25 <div id="counter">
26 <p>Count: <strong data-text="{$count->ref()}">
27 {$count->int()}
28 </strong></p>
29 <button data-on:click="@post('{$increment->url()}')">
30 +1
31 </button>
32 </div>
33 HTML);
34 }
35}
36
37$app = new Via(new Config());
38$app->mount(Counter::class, '/');
39$app->start(); 1<?php
2require __DIR__ . '/vendor/autoload.php';
3
4use Mbolli\PhpVia\Attributes\Action;
5use Mbolli\PhpVia\Attributes\Signal;
6use Mbolli\PhpVia\Config;
7use Mbolli\PhpVia\Context;
8 +use Mbolli\PhpVia\Scope;
9use Mbolli\PhpVia\Via;
10
11final class Counter {
12 - // TAB scope (default): each visitor has their own counter
13 - #[Signal]
14 + // GLOBAL scope: one shared counter, atomic so no click is lost
15 + #[Signal(Scope::GLOBAL, atomic: true)]
16 public int $count = 0;
17
18 #[Action]
19 public function increment(): void {
20 $this->count++;
21 }
22
23 public function view(Context $c): void {
24 $count = $c->getSignal('count');
25 $increment = $c->getAction('increment');
26
27 $c->view(fn() => <<<HTML
28 <div id="counter">
29 <p>Count: <strong data-text="{$count->ref()}">
30 {$count->int()}
31 </strong></p>
32 <button data-on:click="@post('{$increment->url()}')">
33 +1
34 </button>
35 </div>
36 HTML);
37 }
38}
39
40$app = new Via(new Config());
41$app->mount(Counter::class, '/');
42$app->start();Only this tab sees this count.
Both counters run on this server. Open the page in a second window to compare them.
How a php-via page works
php-via runs your app as one long-lived OpenSwoole process. Page state stays in memory between requests, so the server can push a change the moment it happens. A click posts to an action and the action changes signals. php-via sends the new values to the tab, and a write to a shared signal or a broadcast sends new HTML to every tab that should see it.
-
$c->signal(0, 'count')Signal
State held on the server. The browser keeps a copy, and Datastar updates the page when it changes.
-
$c->action(fn)Action
A PHP function the browser calls with a POST, for a click or a keystroke. It changes signals.
-
$c->view(…)View
HTML from Twig or a PHP heredoc. On
sync()or a broadcast, php-via renders it again and sends the new HTML. -
GET /_sseBrowser
Datastar reads the patch from the tab's SSE stream and morphs it into the page without a reload.
Scopes
A scope decides who shares a signal and who receives a broadcast. The default is
Scope::TAB, so state is private until you choose otherwise.
Read the scopes guide.
| Scope | Shared by |
|---|---|
Scope::TAB | one browser tab (the default) |
Scope::ROUTE | everyone on the same route |
Scope::SESSION | all tabs of one browser session |
Scope::GLOBAL | every visitor on every route |
'room:42' | a scope you name; broadcast to room:* to reach them all |
What php-via includes
PHP and HTML only
Datastar reads attributes such as data-on:click="@post(...)" and talks to the server for you. You maintain no JS files and no client-side state.
Runs without a build step
php app.php starts the server. php-via serves its own datastar.js, so you need neither a bundler nor npm before the first page loads.
Closures or classes
Write a page as a closure for a quick script, or as a class with #[Signal] and #[Action] attributes for a larger app. Both run on the same engine.
Scoped state
TAB, ROUTE, SESSION, GLOBAL, or your own scopes like room:42. One line decides whether state is private to a tab or shared with everyone.
Atomic shared signals
Give a signal ROUTE scope and every visitor on the route sees its changes, on every worker. One machine needs no pub/sub service, and increment() keeps simultaneous clicks from overwriting each other.
One SSE stream per tab
A single persistent connection carries every update for the tab. With withBrotli(), php-via compresses the stream itself and the reverse proxy passes it through.
Components
Build a page from isolated sub-contexts, written as closures or classes. Each component has its own signals, actions and view.
Multi-core workers
withWorkerNum() spreads tabs over every core. Scoped signals and session data live in shared memory, and a request that reaches another worker goes on to the tab's.
Dev Bar
A debug overlay with a request trace, the raw SSE patches, and live signal, scope, stats and log panels. It runs on this page: open the via pill in the bottom-right corner.
Web components
With withDatastarRocket(), Rocket components such as Starbase's run on php-via pages. The QR code, the copy buttons and the visitor count on this page are Starbase components.
A live poll with ROUTE scope
The tally lives on the server, and the poll component has Scope::ROUTE, so every
browser on this page renders the same bars. Vote here, or open a second window and vote there.
Which php-via scope is your favorite?
Comparison with Livewire, htmx, Inertia.js and LiveView
Livewire and Inertia.js send a new HTTP request when something changes, and htmx does the same unless you add its SSE extension. php-via keeps one connection open per tab and pushes changes down it, like Phoenix LiveView, which inspired its architecture. Hover or focus a mark for the details, or read the comparisons page.
| Feature | php-via | Livewire | htmx + Alpine | Inertia.js | LiveView |
|---|---|---|---|---|---|
| Native real-time push | ✓One SSE stream per tab carries every update, and any action, timer or broadcast can push to it. | ~Server push needs Laravel Echo plus a broadcaster such as Reverb, Pusher, Ably or Mercure. Livewire alone offers polling and in-request streaming with wire:stream. | ~The SSE extension streams HTML into the page, but the server side is yours to build. | ✗No push channel of its own: use polling, or a separate broadcaster with Laravel Echo. | ✓A persistent WebSocket carries diffs from the server. |
| Multiplayer / shared state | ✓Scoped signals: one line shares state with everyone on a route, in a session, in a room or on the whole site. | ~No shared server-side state. Multiplayer UIs broadcast Laravel events through Echo and re-render from the database. | ✗ | ✗ | ✓Phoenix PubSub broadcasts between LiveView processes. |
| Scoped state system | ✓TAB, ROUTE, SESSION, GLOBAL, or your own scopes like room:42, with wildcard broadcasts. | ✗ | ✗ | ✗ | ~PubSub topics achieve similar results without a declarative scope system. |
| Presence (who is online) | ✓getClients() and countClients() see the tabs on every worker, onClientConnect() and onClientDisconnect() run as tabs come and go, and a closed tab disconnects at once. | ~Laravel presence channels, through Echo and a broadcaster. | ✗ | ✗ | ✓Phoenix.Presence tracks users across nodes. |
| No JS to author | ✓No JS files, components or client state. Datastar attributes take one-line expressions such as @post(url). | ✓Typical components use only wire: directives and Blade. Alpine ships bundled for optional client-side behaviour. | ~htmx requests are plain HTML attributes, but Alpine state and behaviour are inline JavaScript expressions. | ✗Pages are React, Vue or Svelte components. | ~JS hooks are needed for some client-side interactions. |
| No build step | ✓php-via serves its own datastar.js. No bundler, no npm. | ✓Livewire injects its script and Alpine automatically. The documented Echo setup for push uses npm and Vite. | ✓ | ✗npm and Vite build the client. | ~No Node.js: Mix runs standalone esbuild and Tailwind binaries, but the app still bundles its assets. |
| File uploads | ✓Multipart form submission with Datastar's contentType: 'form' modifier. Files arrive in $c->file(). | ✓Temporary uploads with previews, progress events, validation and direct-to-S3 uploads. | ✓ | ✓ | ✓Built-in progress, drag and drop, previews and direct-to-cloud uploads. |
| Form validation | ~No validation API: validate in the action with plain PHP or any validator library, and send per-field errors back as signals or re-rendered HTML. With contentType: 'form', the browser's HTML5 checks run first. | ✓Laravel's validator through #[Validate] attributes or form objects. | ~HTML5 checks in the browser. Server-side errors come back as HTML from your backend. | ✓Server validation errors arrive as props from the backend adapter. | ✓Ecto changesets validate on change and on submit. |
| Auth / middleware | ✓PSR-15 middleware per route or group. Actions are bound to the session and check the Origin header. | ✓ | ✓Provided by the backend you pair it with. | ✓ | ✓ |
| Built-in debugging / observability | ✓The Dev Bar ships with the framework and turns on with dev mode: a request-trace waterfall, the raw SSE patches, and live signal, scope and log panels. | ~No built-in inspector. Laravel Debugbar detects Livewire components, plus Telescope and community browser extensions. | ~Console event logging plus third-party htmx and Alpine DevTools extensions, with no in-page inspector. | ✓The first-party Inertia DevTools browser panel records every visit with its props and headers (Laravel adapter). | ✓Phoenix LiveDashboard (real-time metrics, requests, processes) plus HEEx debug annotations. |
| Multi-core on one server | ✓withWorkerNum(), with SwooleBroker by default: scoped signals, GlobalState, session data and the client list live in shared memory, and an action reaches its tab on any worker. | ✓ | ✓ | ✓ | ✓ |
| Multi-server | ~RedisBroker or NatsBroker carries broadcasts between servers. Use sticky sessions and keep shared state in a database. | ✓ | ✓ | ✓ | ✓Distributed PubSub and Presence across nodes. |
| Runs without a long-running process | ✗Needs a long-running OpenSwoole 26 process behind a reverse proxy. No PHP-FPM or shared hosting. | ✓Plain PHP-FPM. Real-time push adds a Reverb process. | ✓Any backend. Under PHP-FPM every open SSE stream ties up a worker. | ✓Plain PHP-FPM. Server-side rendering adds a Node.js process. | ✗Runs as a long-lived BEAM release. |
| No server memory per open tab | ✗Every open tab holds its context and its SSE stream in server memory. | ✓Component state travels with each request in a checksummed snapshot. | ✓ | ✓ | ✗Each LiveView is a BEAM process that holds its state. |
| Ecosystem & community | Early | Large | Large | Large | Large |
| Framework required | Nophp-via is its own server. Next to a Laravel or Symfony app it runs as a separate process. | Laravel | No | React, Vue or SveltePlus a server adapter: Laravel is first-party, and community adapters cover Rails, Phoenix, Django, Symfony and more. | Phoenix |
| Language | PHP | PHP | PHP + JS | PHP + JS | Elixir |
| Runtime | OpenSwoole 26 | PHP-FPM or Octane | Any backend | PHP-FPM, Node for SSR | BEAM VM |
Install php-via
php-via needs PHP 8.4 or newer and the OpenSwoole 26 extension. Composer installs the rest.
Put the counter from above in app.php and start it.
composer require mbolli/php-via php app.php [INFO] Via server started on http://0.0.0.0:3000