Real-time PHP

PHP finally has
real-time superpowers.

php-via turns your PHP into a reactive, multiplayer-ready server. No JavaScript. No build step. No API layer. Just PHP and OpenSwoole.

1 person on this website right now

This is all the code.

A counter in ~15 lines of PHP. Switch the scope tab to see the only change needed to make it multiplayer — one line. Flip the API toggle to compare the closure handler with the class-based composition API. No JavaScript. No WebSockets. No API glue.

 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);
13
14    $increment = $c->action(
15        fn() => $count->setValue($count->int() + 1)
16    );
17
18    $c->view(fn() => <<<HTML
19        <p>Count: <strong data-text="\${$count->id()}">
20            {$count->int()}
21        </strong></p>
22        <button data-on:click="@post('{$increment->url()}')">
23            +1
24        </button>
25    HTML);
26});
27
28$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 +    // GLOBAL scope — one shared counter for every visitor
14 +    $c->scope(Scope::GLOBAL);
  15    $count = $c->signal(0);
  16
  17    $increment = $c->action(
  18        fn() => $count->setValue($count->int() + 1)
  19    );
  20
  21    $c->view(fn() => <<<HTML
  22        <p>Count: <strong data-text="\${$count->id()}">
  23            {$count->int()}
  24        </strong></p>
  25        <button data-on:click="@post('{$increment->url()}')">
  26            +1
  27        </button>
  28    HTML);
  29});
  30
  31$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            <p>Count: <strong data-text="\${$count->id()}">
26                {$count->int()}
27            </strong></p>
28            <button data-on:click="@post('{$increment->url()}')">
29                +1
30            </button>
31        HTML);
32    }
33}
34
35$app = new Via(new Config());
36$app->mount(Counter::class, '/');
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;
 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 for every visitor
15 +    #[Signal(Scope::GLOBAL)]
  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            <p>Count: <strong data-text="\${$count->id()}">
  29                {$count->int()}
  30            </strong></p>
  31            <button data-on:click="@post('{$increment->url()}')">
  32                +1
  33            </button>
  34        HTML);
  35    }
  36}
  37
  38$app = new Via(new Config());
  39$app->mount(Counter::class, '/');
  40$app->start();
Your counter — private to your tab
0
Only visible to you
Live — shared with everyone on this page
276
Last click: Visitor #499B

PHP deserves real-time.

The PHP ecosystem bent itself into a pretzel trying to add real-time to the stack. WebSockets bolted onto traditional PHP-FPM. Livewire polling every second. Inertia.js adding a React layer on top of Laravel. All of these add complexity, JavaScript, and build tooling to solve a problem that should be solved at the server.

php-via takes a different approach. Built on OpenSwoole's persistent event loop, every page maintains a live SSE connection. When your PHP changes state, the browser updates instantly — no polling, no WebSocket handshake ceremony, no client-side state management. The server is the source of truth, and the browser is a live view into it.

The scope system is the killer feature. Set a signal to Scope::ROUTE and it automatically synchronizes across every browser on that route. Change it to Scope::SESSION and it follows the user across their tabs. Change it to Scope::GLOBAL and every user in the entire application sees the update. One line of code changes who sees what.

We built this because PHP developers deserve the same multiplayer-by-default experience that Elixir's LiveView gives to Elixir developers. Without needing to learn Elixir.

Everything you need. Nothing you don't.

🚫

No JavaScript

Write server-side PHP. Datastar handles client-side reactivity through HTML attributes. Zero JavaScript to author.

No build step

php app.php and you're running. No bundler, no transpiler, no npm install before you can see a page.

🏗️

Closures or classes

Write pages as closures for quick scripts, or as classes with #[Signal] and #[Action] attributes for larger apps. Same engine — flip the toggle above to compare.

🎯

Scoped state

TAB, ROUTE, SESSION, and GLOBAL scopes. One line changes whether state is private to a visitor or shared across everyone.

🤝

Multiplayer by default

Route-scoped signals broadcast to every user on the page. Build collaborative apps without pub/sub infrastructure.

🔄

Single SSE stream

One persistent connection per client carries all updates. Compresses exceptionally well with Brotli over a reverse proxy.

🧩

Components

Compose complex pages from isolated sub-contexts. Each component gets its own signals, actions, and rendering scope.

🔍

Built-in Dev Bar

A zero-config debug overlay: a request-trace waterfall plus live signal, log, and connection inspectors. It's running on this page — open the via pill in the bottom-right corner.

Vote. Watch it update everywhere.

Cast a vote. Every browser on this page sees the bars shift in real-time. That's Scope::ROUTE broadcasting in action.

Live poll — vote updates for everyone instantly

What's your favorite php-via scope?

6
0
2
1

Three primitives. That's the whole API.

Every php-via application is built from signals (reactive state), actions (event handlers), and views (rendering). The framework wires them together over SSE automatically.

1

Signal

Declare reactive state. The browser watches it and updates when it changes.

$c->signal(0, 'count')
2

Action

Handle browser events server-side. Modify signals in the handler.

$c->action(fn() => ...)
3

View

Render HTML with Twig or inline PHP. The framework pushes updates via SSE.

$c->view('page.twig')
🌐

Browser

Datastar applies the HTML patch. No page reload. No flash. Instant.

Honest comparison.

Every tool has trade-offs. Here's where php-via stands.

Feature php-via Livewire htmx + Alpine Inertia.js LiveView
Native real-time push ~Requires Laravel Reverb + Echo for server push; native Livewire uses HTTP round-trips. ~SSE extension available but requires manual wiring.
Multiplayer / shared state
Scoped state system ~PubSub topics achieve similar results without a declarative scope system.
No JS to author ~Alpine.js is bundled; complex interactions may need JS hooks. ~JS hooks needed for some client-side interactions.
No build step
File uploads Via multipart/form-data form submission using Datastar's contentType: 'form' modifier. Files arrive in $c->file().
Form validation HTML5 client-side (Datastar calls reportValidity() before sending) plus full server-side validation with per-field error signals pushed back via SSE. ~HTML5 constraint API only; no built-in server-side validation.
Auth / middleware
Built-in debugging / observability Zero-config Dev Bar ships with the framework: an in-page request-trace waterfall plus live signal, log, and connection inspectors. Toggled with Config::withTracing(). ~No built-in overlay; relies on Laravel Telescope and Debugbar (separate packages) plus community browser DevTools extensions. ~htmx.logAll() console logging plus third-party htmx and Alpine.js browser DevTools extensions; no built-in in-page inspector. ~Leans on Laravel Telescope server-side, Vue/React DevTools client-side, and a community Inertia.js DevTools extension. First-party Phoenix LiveDashboard (real-time metrics, requests, processes) plus HEEx debug annotations.
Ecosystem & community Early Large Medium Large Large
Multi-worker / multi-server scaling ~Same machine: withWorkerNum() + SwooleBroker (no external deps, sticky sessions required). Multi-server: RedisBroker or NatsBroker. One line in Config.
Framework required No Laravel No AnyPrimarily targets Laravel but also supports Rails, Django, and Phoenix. Phoenix
Language PHP PHP PHP + JS PHP + JS Elixir
Runtime OpenSwoole PHP-FPM PHP-FPM PHP-FPM BEAM VM

Try it in under 5 minutes.

composer require mbolli/php-via