Sitelet https://github.com/mbolli/php-via
Skip to content
mbolliPublic

About

Real-time engine for building reactive web applications in PHP with Swoole.

Topics

Resources

Stars

90 stars

Watchers

0 watching

Forks

Repository files navigation

php-via

Latest Version on Packagist Total Downloads License PHP Version CI PHPStan Docs

php-via

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/twig is 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

onCleanup fires after a grace period (default: 5 seconds) that tolerates page navigation and brief reconnects without tearing down state. Tune it with Config::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 under Config::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. With withWorkerNum() 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.1","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

License

MIT

About

Real-time engine for building reactive web applications in PHP with Swoole.

Topics

Resources

Stars

90 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages