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:

  1. Browser opens wss://<host>/websocket.
  2. phloWS calls wsConnect. On false: close.
  3. The first incoming message must be the auth payload (typically {type: 'auth', token: '<string>'}).
  4. phloWS calls wsAuth($wsHost, $wsToken, $wsSocket). The app validates against %user, %session->token or a custom lookup.
  5. On false: close. On true: the socket is marked authenticated; everything after that goes through wsReceive.

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:

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

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.

We use essential cookies to make this site work. With your permission we also use analytics to improve the site.