14: WebSocket
Realtime in Phlo runs through phloWS, a separate Node.js server (phloWS.js) that multiplexes WebSocket connections across multiple vhosts and hands every incoming message off as a one-shot PHP CLI call. Your app implements four hook functions and broadcasts from PHP with wsCast().
14.1: What phloWS is
phloWS is a light broker written in Node.js (ws library, ~9 KB of code). One process on port 3001 serves the entire stack: a single phloWS routes to the right app via the Host header of the WS handshake.
For each incoming message phloWS boots a one-shot PHP process (php-zts <app>/www/app.php ws::<event>). That costs ~50-100 ms per message, but gives every handler the complete request lifecycle: DB, session, resources, everything is simply available.
No persistent worker state between messages. If you want to share state, do it through your database or apcu.
14.2: Installation
phloWS lives outside the Phlo framework. Pick a path, /srv/websocket, /opt/phloWS, ~/code/phloWS, it doesn't matter. We use <ws> as a placeholder:
git clone https://github.com/q-ainl/phlo-websocket.git <ws>
cd <ws>
npm install
In <ws>/websocket.js you map vhost → app.php path:
require('./phloWS.js')(3001, '/usr/bin/php-zts', {
'app.example.com': '<app>/release/www/app.php',
'dev.app.example.com': '<app>/www/app.php',
})
Run it as a service (systemd / pm2 / supervisord); the phlo-websocket README describes the pm2 run pattern and the /message bridge contract:
node <ws>/websocket.js
For production: pass wss:// through your reverse proxy (Caddy, Nginx, FrankenPHP) to 127.0.0.1:3001 for the path /websocket.
14.3: 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) return false
%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 | Return |
|---|---|---|
wsConnect |
Right after the WS handshake | true accepts, false closes |
wsAuth |
First auth message from the client | true authenticates, false closes |
wsReceive |
For every subsequent message (JSON-decoded and spread) | irrelevant, use wsCast() to respond |
wsClose |
Connection closes | irrelevant |
$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.4: Auth flow
phloWS implements a two-step handshake:
- Browser opens
wss://<host>/websocket. - phloWS calls
wsConnect. Onfalse: close. - The first incoming message must be the auth payload (typically
{type: 'auth', token: '<string>'}). - phloWS calls
wsAuth($wsHost, $wsToken, $wsSocket). The app validates against%user,%session->tokenor a custom lookup. - On
false: close. Ontrue: the socket is marked authenticated; everything after that goes throughwsReceive.
The token typically comes from %user->token (per logged-in user) or an API key. The client can send it along via a cookie or as the first WS message.
14.5: Broadcasting from PHP
wsCast() is a regular function (resource wsCast). It does a POST to phloWS' internal HTTP bridge, which pushes it on to the right sockets.
wsCast(wsTarget: 'all', toast: 'New message received')
wsCast(wsTarget: $wsSocket, path: '/inbox')
wsCast(wsTarget: ['s1', 's2'], inner: ['#count' => $newCount])
| Argument | Default | Meaning |
|---|---|---|
wsTarget |
'all' |
'all', 'token:<id>', 'token:not:<id>', a single socket id, or an array of socket ids |
wsHost |
host |
Vhost the broadcast applies to (default: current host) |
wsPort |
websocket (constant from app config) |
phloWS 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 phloWS is down, the POST fails silently. For guaranteed delivery (financial events): combine with a DB queue.
14.6: 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.7: 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.8: Known limitations
- One-shot CLI per event, every message costs a PHP startup. Fine for inbox, presence and notifications; unsuitable for high-frequency telemetry or real-time trading.
- No versioning on payloads, when refactoring: migrate all clients at once.
- Single point of failure, one phloWS process for the entire stack. On a crash: all realtime features are down until restart. Run phloWS under a process supervisor.
- No built-in encryption, use your reverse proxy for TLS termination (
wss://).
14.9: Streaming responses: realtime-light without phloWS
Not every progressive update needs a WebSocket. Set %res->streaming = true in a route and every subsequent apply(...) is printed and flushed immediately as one JSON line over the same HTTP response; phlo.js keeps applying the commands as they arrive:
route async POST report::generate {
%res->streaming = true
foreach ($this->steps AS $i => $step){
$step->run
apply(inner: arr('#progress' => $i + 1 .'/'. count($this->steps)))
}
apply(toast: 'Done')
}
Use streaming when one client triggered the work and watches its own progress (AI token streams, imports, batch jobs). Use phloWS when OTHER clients must receive the update too: streaming follows the request, broadcasting follows the fleet of connections.