Real-time reactive web framework for PHP. Server-side reactive UIs with zero JavaScript, using OpenSwoole for async PHP, Datastar (RC.8) for SSE + DOM morphing, and Twig for templating.
- No JavaScript to write — Datastar handles client-side reactivity, SSE, and DOM morphing
- Twig templates — familiar, powerful server-side templating
- 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
- PHP 8.4+
- OpenSwoole extension
- Composer
composer require mbolli/php-via
<?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);
$app->page('/', function (Context $c): void {
$count = $c->signal(0, 'count');
$step = $c->signal(1, 'step');
$increment = $c->action(function () use ($count, $step, $c): void {
$count->setValue($count->int() + $step->int());
$c->syncSignals();
}, 'increment');
$c->view('counter.html.twig', [
'count' => $count,
'step' => $step,
'increment' => $increment,
]);
});
$app->start();counter.html.twig:
<div id="counter">
<p>Count: <span data-text="${{ count.id }}">{{ 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
Full documentation at via.zweiundeins.gmbh/docs
$name = $c->signal('Alice', 'name');
$name->string(); // read
$name->setValue('Bob'); // write → auto-pushes to browser<input data-bind="{{ name.id }}">
<span data-text="${{ name.id }}">{{ name.string }}</span>$save = $c->action(function () use ($c): void {
$c->sync();
}, 'save');<button data-on:click="@post('{{ save.url }}')">Save</button>| 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->view('dashboard.html.twig', ['user' => $user]);$app->page('/blog/{year}/{slug}', function (Context $c, string $year, string $slug): void {
// ...
});$a = $c->component($counterWidget, 'a');
$b = $c->component($counterWidget, 'b');$c->onDisconnect(fn() => /* cleanup */);
$c->setInterval(fn() => $c->sync(), 2000); // auto-cleaned on disconnect
$app->onClientConnect(fn(string $id) => /* ... */);$c->broadcast(); // same scope
$app->broadcast(Scope::GLOBAL); // all contexts
$app->broadcast('room:lobby'); // custom scope1. 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
git clone https://github.com/mbolli/php-via.git
cd php-via && composer install
cd website && php app.php # run website + examples on :3000
vendor/bin/pest # 101 tests, 258 assertions
composer phpstan # PHPStan level 6
composer cs-fix # code style
Single OpenSwoole process behind a reverse proxy. See deploy/ for systemd + Caddy configs.
Browser → Caddy (TLS + Brotli) → OpenSwoole :3000
- Route groups (
$app->group('/prefix', fn)) -
initAtBoot()— explicit hook for boot-time shared state initialisation - Global intervals (
$app->setInterval()— one shared timer per server process)
- Datastar — SSE + DOM morphing
- OpenSwoole — Async PHP
- Twig — Templating
- go-via/via — Original Go inspiration
MIT
