3: Broadcasting with wsCast
wsCast is how a handler pushes to clients. It posts a message to Phlo Realtime, which fans it out to the targeted sockets, and each browser applies it through the same phlo.js apply pipeline your async routes already use.
3.1: Targets
wsCast(wsTarget: 'all', inner: ['#online' => (string)$count])
wsCast(wsTarget: 'socket:'.$wsSocket, toast: 'Just for you')
wsTarget: 'all'broadcasts to every connected socket.wsTarget: 'socket:'.$wsSocketsends to one socket.- Token-scoped targeting lets you reach every socket for a given user.
Everything after the target is an ordinary apply instruction: inner, outer, prepend, append, toast, scroll, and so on. The client treats a cast exactly like the response to a form, so the same view-update code works for both transports.
3.2: Leaving the sender out
A token identifies a principal, not a connection. One user with two tabs, or one till with a customer display beside it, is one token holding several sockets. That is the point of token targeting, and it is also why token:not: cannot help you skip a sender: it drops every socket of that token, including the other screens you were trying to reach.
wsExcept names one socket to skip, whichever target picked it up:
function wsReceive($wsHost, $wsToken, $wsSocket, ...$data){
wsCast(wsTarget: 'token:'.$wsToken, wsExcept: $wsSocket, inner: $data['inner'])
}
Every other screen of this token gets the message; the one that sent it does not. Use it whenever clients relay through the server to their peers, which is the usual shape for chat, typing indicators and second screens.
The exclusion is a separate argument rather than a target value, because the daemon has no idea who "me" is: a cast is its own HTTP call, unconnected to the socket that triggered your handler. The hook is handed $wsSocket, so the hook is the only place that can say.
Spell the payload out when you pass
wsExcept....$dataafter a named argument is a fatal PHP error.
3.3: wsCast can fail; guard it
wsCast makes an HTTP call to Phlo Realtime. If Phlo Realtime is not running, it throws. Wrap it so the rest of your handler (and your plain async path) keeps working:
function cast(...$args){
try {
wsCast(...$args)
}
catch (\Throwable $e){
}
}
This guard is what makes a Phlo Realtime app degrade cleanly to a non-realtime app when Phlo Realtime is down.
3.4: The browser client
Loading the DOM/websocket resource ships a small client. It opens a socket only when the page body has the class wss, reconnects automatically, and applies incoming casts. To turn realtime on for a page, put the app in that mode (the demo sets prop options = 'wss'); leave it off and the same page is a normal Phlo page.
3.5: Mirroring state between screens
With DOM/store loaded, a client can publish part of its own state instead of hand-rolling messages. Declare the path once:
app.sync('basket') // over the socket, coalesced per 200 ms
app.sync('draft', {post: 'api/draft', delay: 1000})
Every change under that path now leaves as {sync: {basket: <value>}}. Changes are coalesced, so a burst of edits sends one message with the final value, not one per keystroke.
The server decides who may see it. A relay that hands the value to the other screens of the same principal is four lines:
function wsReceive($wsHost, $wsToken, $wsSocket, ...$data){
if (!isset($data['sync'])) return
wsCast(wsTarget: 'token:'.$wsToken, wsExcept: $wsSocket, sync: $data['sync'])
}
Nothing is relayed that you do not relay yourself: pick the paths you accept rather than passing the payload through unread, the same as with any other client input.
On the receiving side sync is an ordinary apply command, so it needs no code. It differs from store in one way that matters: a value applied through sync is not published again. Without that, two screens that both publish the same path would bounce it back and forth forever.
Two more things worth knowing:
- A screen that connects late has missed everything. The publisher's state has not changed, so nothing is on its way. Let the new screen ask (
app.websocket.send({want: 'basket'})), and have the publisher answer withapp.push('basket'), which sends the current value even though nothing changed.app.websocket.connectis the hook that fires on every connect and reconnect. - Two-way sharing works, because
wsExceptkeeps a sender from hearing itself. Both screens declare the same path, and each sees the other's edits. Last write wins; the store carries no merge strategy.
Last updated on 23 August 2026