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:

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:

  1. The browser opens wss://<host>/websocket. The cookies for that origin, including a token cookie, ride along on the upgrade request.
  2. The daemon reads the token cookie. If it is absent, the upgrade is refused with 401.
  3. The daemon runs websocket::auth($wsHost, $wsToken, $wsSocket) on its pool, which calls your wsAuth.
  4. wsAuth validates the token against %user, %session->token or a custom lookup. Raise an error (error('unauthorized')) to refuse: a thrown auth fails the upgrade. On success the socket opens and wsConnect runs.

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:

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

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