14: WebSocket
Realtime in Phlo runs through Phlo Realtime, the WebSocket server built into the Phlo Daemon. One Node process owns the socket connections across every vhost and runs each event on your PHP app through the daemon's own worker pool. Your app implements four hook functions and broadcasts from PHP with wsCast().
14.1: Streaming first
Not everything realtime needs a socket. For one-way updates over a single request, chunk() (the chunk resource) opens a streaming response and flushes each call immediately as one JSON line over plain HTTP; phlo.js applies the commands as they arrive:
route async POST report::generate {
foreach ($this->steps AS $i => $step){
$step->run
chunk(inner: arr('#progress' => $i + 1 .'/'. count($this->steps)))
}
apply(toast: 'Done')
}
%res->streaming = true gives a route the same behaviour with plain apply() calls, and classic SSE (text/event-stream) fills the identical one-way slot. All of these hold a PHP worker for as long as the response streams: exactly right for finite work that one client watches (AI token streams, imports, reports), and exactly wrong for a standing connection that sits waiting for events. That is what Phlo Realtime is for: the daemon holds the open sockets, your PHP runs only when an event arrives, and a broadcast reaches every connected client instead of only the one that asked. Streaming follows the request; broadcasting follows the fleet of connections.
14.2: What Phlo Realtime is
Phlo Realtime is the WebSocket layer of the daemon (built on the ws library), not a separate process. The single daemon on port 3001 serves the whole stack: it accepts the socket upgrades, routes by the Host header of the handshake, keeps the client registry, runs the /message broadcast bridge, and dispatches every socket event to your PHP on the same worker pool the daemon already uses for everything else.
Because that dispatch is in-process, there is nothing to wire up between "the socket layer" and "the PHP layer": they are one process. Each event (auth, connect, receive, close) runs the matching websocket::<hook> target, and the execution mode is whatever the daemon already does for that host:
- One-shot (a
build: truehost, i.e. dev). A fresh PHP process per event, the app booted each time: dead simple, fully isolated, hot-reload. Ideal for development and low-traffic hosts. - Resident pool (a release host). The daemon's pool keeps workers warm and answers events over a pipe, no per-event startup. The pool scales itself up to demand and back down when idle, so there is no worker count to configure. Worker-safe handlers apply, the same discipline as FrankenPHP worker mode: no request or user state in
statics, always commit or roll back DB work.
Either way each event gives the handler the full request lifecycle: DB, session, resources, everything is simply available. The mode follows the host's build flag (set in the host's entry in config/daemon.js, see below); the handler code is identical.
14.3: Installation
The daemon is one Node service that lives outside the Phlo framework. Run it, point your reverse proxy at it, and that is the whole realtime setup.
git clone https://github.com/q-ainl/phlo-daemon.git <daemon>
cd <daemon>
npm install
It takes a port, the PHP binary and the host map:
// <daemon>/config.js
require('./phlo-daemon.js')(3001, '/usr/bin/php-zts', {
'demo.example.nl': { app: '/var/www/demo/www/app.php', build: true },
})
The third argument is the host map: it pins each Host to its app.php path and a build flag, and is declared in config/daemon.js. The daemon loads it at startup, so it always knows which hosts exist and whether each is one-shot or pooled. A host with no entry fails the dispatch, which fails the upgrade.
Run it under a process manager (systemd / pm2 / supervisord); the phlo-daemon README describes the pm2 pattern and the /message bridge contract:
node <daemon>/config.js
For production, pass wss:// through your reverse proxy (Caddy, Nginx, FrankenPHP) to 127.0.0.1:3001 for the path /websocket. Your app declares the same port as the daemon constant in www/app.php:
phlo_app(
app: __DIR__.'/../',
daemon: 3001,
)14.4: App hooks
In your app source you define four functions; Phlo's websocket resource calls them if they exist. Put them in a file like app.ws.phlo: do not name the file websocket.phlo, because that class name collides with the engine's websocket resource when it is loaded.
function wsConnect($wsHost, $wsToken, $wsSocket){
%log->info('ws connect', socket: $wsSocket)
return true
}
function wsAuth($wsHost, $wsToken, $wsSocket){
$user = %user->byToken($wsToken)
if (!$user) error('unauthorized')
%session->user = $user
return true
}
function wsReceive($wsHost, $wsToken, $wsSocket, ...$data){
$type = $data['type'] ?? null
if ($type === 'ping') return wsCast(wsTarget: $wsSocket, pong: time())
if ($type === 'chat.send') chat::send($data['text'], from: %session->user->id)
}
function wsClose($wsHost, $wsToken, $wsSocket){
%log->info('ws close', socket: $wsSocket)
}
| Hook | When | Notes |
|---|---|---|
wsAuth |
At the handshake, before the socket is accepted | Validate $wsToken; raise an error to refuse the connection |
wsConnect |
Right after the socket is accepted | Set-up (presence, logging); broadcast with wsCast() |
wsReceive |
For every subsequent message (JSON-decoded and spread) | Respond with wsCast(); printed lines stream back to the sender |
wsClose |
The connection closes | Clean-up (presence); broadcast with wsCast() |
$wsSocket is an opaque string identifier you can use to broadcast back to exactly this client.
The connection-context arguments are ws-prefixed by convention ($wsHost, $wsToken, $wsSocket), exactly like wsCast. This is not cosmetic: wsReceive spreads the JSON payload into named arguments (...$data), so an unprefixed $host/$token/$socket parameter would fatally collide with a payload that carries a host, token or socket key. Keep the prefix and your payload keys stay free.
14.5: Auth flow
The daemon authenticates at the handshake, before it accepts the socket:
- The browser opens
wss://<host>/websocket. The cookies for that origin, including atokencookie, ride along on the upgrade request. - The daemon reads the
tokencookie. If it is absent, the upgrade is refused with401. - The daemon runs
websocket::auth($wsHost, $wsToken, $wsSocket)on its pool, which calls yourwsAuth. wsAuthvalidates the token against%user,%session->tokenor a custom lookup. Raise an error (error('unauthorized')) to refuse: a thrown auth fails the upgrade. On success the socket opens andwsConnectruns.
The token typically comes from %user->token (per logged-in user) or an API key, set as the token cookie when the page is served. The browser sends it automatically on the WS handshake; the client sends no separate auth message.
14.6: Broadcasting from PHP
wsCast() is a regular function (resource wsCast). It does a POST to the daemon's internal /message bridge, which pushes it on to the right sockets.
wsCast(wsTarget: 'all', toast: 'New message received')
wsCast(wsTarget: 'socket:'.$wsSocket, path: '/inbox')
wsCast(wsTarget: 'token:'.$token, inner: ['#count' => $newCount])
| Argument | Default | Meaning |
|---|---|---|
wsTarget |
'all' |
'all', 'token:<id>', 'token:not:<id>' or 'socket:<id>' |
wsHost |
host |
Vhost the broadcast applies to (default: current host) |
wsPort |
daemon (constant from app config) |
The daemon's port |
...$data |
none | Named args become the payload, usually apply() commands |
The payload is passed through to the client and applied to the DOM automatically by phlo.js: the same apply() protocol you know from async routes.
No retry, no dead-letter, no ACK. If the daemon is down, the POST fails silently. For guaranteed delivery (financial events): combine with a DB queue.
14.7: Client side
The client itself does nothing special. Add DOM/websocket to your resources in data/app.json:
{
"resources": [..., "DOM/websocket", "wsCast"]
}
DOM/websocket injects a script that:
- connects automatically to
wss://<host>/websocket - pipes incoming messages straight through
apply(),inner:,outer:,class:,toast:,path:work the same as with async routes - reconnects with exponential backoff (333 ms, 999 ms, ...)
If you want to send from JS: app.websocket.send({type: 'chat.send', text: 'hi'}).
14.8: Mini example: presence
Show "who is online" without polling.
function wsConnect($wsHost, $wsToken, $wsSocket){
%apcu->set("presence:$wsSocket", time(), 3600)
wsCast(wsTarget: 'all', inner: ['#online-count' => static::count()])
return true
}
function wsClose($wsHost, $wsToken, $wsSocket){
%apcu->delete("presence:$wsSocket")
wsCast(wsTarget: 'all', inner: ['#online-count' => static::count()])
}
static count(){
$keys = %apcu->keys('presence:')
return count($keys)
}
The server keeps no state; APCu counts sockets per host. On a PHP restart the cache empties by itself, which is fine, because an empty presence is an acceptable degraded state.
14.9: Known limitations
- One-shot mode costs a PHP startup per event. Fine for inbox, presence and notifications; for high-frequency telemetry run the host as a release build so the daemon serves it from its resident pool, which removes that cost. A pooled worker handles one event at a time and the pool sizes itself to demand, so keep long-running handlers off the hot path (restart the workers after a deploy so they reload).
- No versioning on payloads, when refactoring: migrate all clients at once.
- One process for the stack. The daemon owns the sockets and runs the PHP; if it crashes, realtime (and any pooled dispatch) is down until restart. Run it under a process supervisor.
- No built-in encryption, use your reverse proxy for TLS termination (
wss://).