8: Realtime
One gap remains: when someone else votes, your tab does not move. In this final chapter the server pushes results to every open browser through phloWS, and then you ship the whole thing. The realtime part is optional: it requires the separate phlo-websocket Node service, and the poll is complete without it. The shipping part is not optional.
8.1: phloWS, the optional Node service
WebSockets in Phlo run through phloWS, a small Node.js broker that lives outside the framework. One phloWS process serves all your apps on one port and routes connections by Host header. For every incoming message it makes a one-shot PHP call into your app, so your handlers get the full request lifecycle: database, session, everything.
git clone https://github.com/q-ainl/phlo-websocket.git <ws>
cd <ws>
npm install
In <ws>/websocket.js, map your vhost to the app entrypoint, then run it:
require('./phloWS.js')(3001, 'php', {
'localhost': '<app>/www/app.php',
})
node <ws>/websocket.js
On the app side, give the runtime its WebSocket port in www/app.php and add the realtime resources to data/app.json:
phlo_app(
id: 'Poll',
host: 'localhost',
build: true,
debug: true,
app: dirname(__DIR__).'/',
langs: dirname(__DIR__).'/langs/',
websocket: 3001,
);
{
"resources": ["...", "websocket", "DOM/websocket", "wsCast", "HTTP"]
}
(... stands for everything already in your list.) websocket dispatches incoming events to your hooks, DOM/websocket is the browser client that connects automatically and pipes incoming messages through the same apply() machinery you used in chapter 6, and wsCast plus HTTP let PHP broadcast. In production you pass wss://.../websocket through your reverse proxy to port 3001. Rebuild; lint stays [].
8.2: The hooks
Your app reacts to socket events through plain functions. Create poll.ws.phlo:
function wsConnect($host, $token, $socket){
return true
}
function wsReceive($host, $token, $socket, ...$data){
if (($data['type'] ?? null) === 'ping') return wsCast(wsTarget: $socket, pong: time())
}
wsConnect runs after the handshake; return true to accept. wsReceive runs for every client message, JSON-decoded and spread into $data. Two more hooks exist (wsAuth for token auth, wsClose for cleanup); the poll needs neither, because results are public. $socket is an opaque id you can target directly, as the ping reply shows.
One naming trap: do not call this file websocket.phlo. That class name is taken by the engine's websocket resource and the build fails with a duplicate class error. poll.ws.phlo compiles the functions into the app just fine.
8.3: Broadcast on vote
wsCast() posts to phloWS, which pushes to connected browsers. The payload is the same command language as apply(). One line in the vote route:
route both POST poll vote $id {
if (!$option = type_poll::record(id: (int)$id)) return false
type_poll::change('id=?', (int)$id, votes: $option->votes + 1)
wsCast(wsTarget: 'all', outer: ['#results' => $this->results])
if (%req->async) return apply(
outer: ['#results' => $this->results],
)
location('/poll')
}
wsTarget: 'all' reaches every connected client; a socket id or an array of ids narrows it down. The browsers receive outer: {'#results': ...} and update the bars, exactly as if they had voted themselves.
Open http://localhost/poll in two windows and vote in one: both move. And if phloWS is down? wsCast() fails silently, voters still get their apply() response, and the poll keeps working. Realtime is a layer here, not a dependency.
8.4: Ship it
Time to produce a deployable build. Add a release target to data/app.json:
{
"resources": ["..."],
"release": "%app/release/"
}
Then build it:
php www/app.php build::run
php www/app.php build::lint
php www/app.php build::release
build::release compiles a minified production build: PHP into release/, web assets into release/www/. Give the release its own entrypoint, release/www/app.php, with build mode off and the paths pointed at the release output:
<?php
require '/phlo/phlo.php';
phlo_app(
id: 'Poll',
host: 'poll.example.com',
build: false,
app: dirname(__DIR__, 2).'/',
php: dirname(__DIR__).'/',
www: __DIR__.'/',
data: dirname(__DIR__, 2).'/data/',
langs: dirname(__DIR__, 2).'/langs/',
websocket: 3001,
);
data and langs point at the shared app folders: the votes and translations are state, not build output. Deploy with the same Docker image you started with, now with release/www as the webroot and automatic HTTPS:
docker run -v $(pwd)/app:/app -p 80:80 -p 443:443 -p 443:443/udp \
-e SERVER_NAME=poll.example.com ghcr.io/q-ainl/phlo8.5: Where next
You built a full-stack app in eight short files: a model, a controller, a style sheet, a hooks file, and four config touches. Routing, views, CSS, ORM, async DOM updates, translations, and realtime, all in one language.
From here:
- The guide covers every layer in depth: route grammar, the apply protocol, the ORM's relations and schemas, worker mode, tasks, AI resources, and tracing.
- The ecosystem page shows the production platform around your app: FrankenPHP worker mode, the Phlo Dashboard, phloWS and phloWA, and fleet management.
Ship something.