Design decisions
php-via makes three architectural bets that differentiate it from every alternative in the PHP ecosystem. They're constraints as much as features — each one closes off certain use cases while enabling others. This page explains the reasoning.
One connection. Brotli dictionary. Near-zero incremental bytes.
Every request/response framework — Livewire, htmx, Inertia — works by making new HTTP requests when something changes. Each round-trip pays the full cost: TCP connection (or reuse overhead), TLS resumption, HTTP headers, and a compressor that starts from a cold dictionary. Even with HTTP/2 and keep-alive, each response is independently compressed.
php-via opens a single SSE connection per client and keeps it open for the entire page session. Datastar, a small JS runtime, receives incremental DOM patches down that stream and morphs the page — not full re-renders, not JSON state trees, just the exact changed fragment. When a reverse proxy like Caddy applies Brotli compression to the SSE stream, the compressor operates on a sliding window over the entire connection. Its dictionary grows richer over time: after a warm-up period, repeated patterns (HTML structure, signal names, attribute values) are already in the dictionary.
In practice this means 100× compression ratios are achievable on high-frequency updates. A counter increment or a single changed boolean — something that would be hundreds of bytes in a fresh Livewire response — becomes single-digit bytes on the wire after the first few dozen updates. (The dictionary resets on SSE reconnect or page navigation, so 100× is a steady-state number, not a guaranteed minimum.)
This matters most on slow or metered connections: mobile data, low-bandwidth regions, satellite. Where a Livewire app is paying per-click HTTP overhead, a php-via app is paying it once at connect time and then sending near-zero bytes per update for the rest of the page session. The difference compounds on high-frequency UIs: live feeds, collaborative dashboards, game boards, tickers that update every second.
The tradeoff is real: a persistent OpenSwoole process is harder to operate than PHP-FPM. The process must be managed as a long-running service, not a stateless CGI handler. OpenSwoole coroutines are lightweight (~8KB each), so holding thousands of open connections is cheap — the real scaling constraint is the single-process shared memory model, not RAM per connection. For teams already running PHP-FPM infrastructure who don't need real-time push, the operational shift may not be worth it.
The server is the source of truth. Always.
Most modern web frameworks treat the client as a peer: you send state to the browser, the browser modifies it, you reconcile. React, Vue, Livewire with wire:model — all of these involve a client-side copy of state that must be kept in sync with the server.
In php-via, the server owns the canonical value of every signal. Datastar maintains a client-side store for display and two-way input binding, but it's a transient cache — not the source of truth. When a user clicks a button or submits a form, the action runs on the server, signals are mutated on the server, and the server pushes the new DOM fragment. The client store is overwritten, not reconciled.
This eliminates an entire class of bugs: stale reads, optimistic update rollbacks, and cache
invalidation. There is no hydration step at all — the server sends ready-to-display HTML
fragments, not serialized state that the client must rehydrate. When users share a scope
(e.g. Scope::ROUTE), they always see exactly what the server says they should
see, with no client-side reconciliation logic to reason about.
The tradeoff: every interaction requires a server round-trip. Animations and gestures that need sub-10ms response times are not a good fit. php-via is designed for UIs where correctness matters more than perceived instantaneity — dashboards, collaborative tools, admin panels, data entry, real-time feeds.
php-via is MPA-first: navigating between pages closes the SSE connection and opens a new one. Each page gets its own context, signals, and Brotli dictionary. This keeps the mental model simple — one page, one context — and the View Transition API can provide SPA-like visual continuity between navigations without client-side routing.
Declarative scope over hand-wired pub/sub.
Building a multiplayer feature in a traditional stack means choosing a pub/sub transport (Redis, Pusher, Ably, a WebSocket server), defining channel names, subscribing and unsubscribing in the client, and writing glue code to translate server events into DOM mutations. It's at least four moving parts even for the simplest case.
php-via replaces all of that with a single declaration:
$c->scope(Scope::ROUTE); // every user on this URL shares signals
$c->scope(Scope::SESSION); // follows the user across their tabs
$c->scope(Scope::GLOBAL); // every connected client, every route
$c->scope('room:lobby'); // a custom group you define Changing one line changes who sees what. There's no channel subscription code in the browser, no backend event bus to configure, no mapping between signal names and channel topics. The framework infers the broadcast topology from the scope, handles subscribe/unsubscribe at connect/disconnect time, and routes patches to the right clients automatically.
The tradeoff is that the scope system is currently single-process only — it uses in-process PHP arrays shared across OpenSwoole coroutines, not an external broker. Multi-server deployments require an external state layer (not yet built). Datapages with NATS is the right choice today if you need multi-instance horizontal scaling.
Next steps
- Scopes — the full scope API and custom scope patterns
- Broadcasting — pushing updates to groups of clients
- Comparisons — how php-via positions against Livewire, htmx, Inertia, LiveView
- Deployment — running OpenSwoole behind Caddy with Brotli enabled