WebSocket
App::ws($path, $onMessage, $onOpen, $onClose) — register a WebSocket endpoint. ZealPHP uses OpenSwoole\WebSocket\Server which is backward-compatible with HTTP routes on the same port.
$app->ws(
'/ws/chat',
// Each handler receives the same 3rd argument: $g — RequestContext::instance()
// for the coroutine the handler is dispatched in. Use $g->session for
// auth lookups (CoSessionManager seeds it from the upgrade request's
// PHPSESSID cookie before onOpen fires), Store/Counter for cross-worker
// shared state, or $g->server for the original upgrade request's headers.
onMessage: function($server, $frame, $g) {
// $frame->data — message text
// $frame->fd — connection id
// $frame->opcode — 1=TEXT, 2=BINARY (PING/PONG filtered automatically)
$server->push($frame->fd, 'echo: ' . $frame->data);
},
onOpen: function($server, $request, $g) {
// $request->fd — connection id
// $request->cookie — cookies from upgrade request
// $request->get — query params from ws://host/path?key=val
$server->push($request->fd, json_encode(['event' => 'connected']));
},
onClose: function($server, $fd, $g) {
// clean up per-connection state
}
);
Live demo — 6 endpoints
WS /ws/echo — mirrors every message back verbatim.
$app->ws('/ws/echo', onMessage: fn($server,$frame) => $server->push($frame->fd, 'echo: '.$frame->data));
WS /ws/broadcast — every message goes to ALL connected clients.
$broadcastClients = [];
$app->ws('/ws/broadcast',
onMessage: function($server, $frame, $g) use (&$broadcastClients) {
foreach (array_keys($broadcastClients) as $fd) {
if ($server->isEstablished($fd))
$server->push($fd, json_encode(['from'=>$frame->fd,'msg'=>$frame->data]));
}
},
onOpen: fn($s,$req) => $broadcastClients[$req->fd] = true,
onClose: fn($s,$fd) => unset($broadcastClients[$fd])
);
WS /ws/ticker — server pushes every 1s using a spawned coroutine.
$app->ws('/ws/ticker',
onMessage: fn($s,$f) => trim($f->data)==='stop' ? $s->close($f->fd) : null,
onOpen: function($server, $request, $g) {
$fd = $request->fd;
go(function() use ($server, $fd) {
$i = 0;
while ($server->isEstablished($fd)) {
co::sleep(1);
$server->push($fd, json_encode(['tick' => ++$i, 'time' => date('H:i:s')]));
}
});
}
);
WS /ws/rooms?room=general — cross-worker rooms via Store (OpenSwoole\Table). Every worker shares the same client registry.
// Shared across all workers — created before run()
Store::make('ws_rooms', 4096, [
'room' => [Store::TYPE_STRING, 64],
'uid' => [Store::TYPE_STRING, 128],
]);
$app->ws('/ws/rooms',
onOpen: fn($server, $request, $g) => Store::set('ws_rooms', (string)$request->fd, [
'room' => $request->get['room'] ?? 'general',
'uid' => $request->get['uid'] ?? 'guest_'.$request->fd,
]),
onMessage: function($server, $frame, $g) {
$me = Store::get('ws_rooms', (string)$frame->fd);
foreach (Store::table('ws_rooms') as $fd => $info)
if ($info['room'] === $me['room'] && $server->isEstablished((int)$fd))
$server->push((int)$fd, json_encode(['from'=>$me['uid'],'msg'=>$frame->data]));
},
onClose: fn($server, $fd, $g) => Store::del('ws_rooms', (string)$fd)
);
WS /ws/auth?token=secret — validates token in onOpen, disconnects with code 4001 if invalid.
$app->ws('/ws/auth',
onOpen: function($server, $request, $g) {
$token = $request->get['token'] ?? null;
if ($token !== 'secret') {
$server->push($request->fd, json_encode(['error' => 'Unauthorized']));
$server->disconnect($request->fd, 4001, 'Unauthorized');
return;
}
$server->push($request->fd, json_encode(['event' => 'authenticated']));
},
onMessage: fn($server, $frame) => $server->push($frame->fd, 'secure: '.$frame->data)
);
WS /ws/binary — checks $frame->opcode, echoes binary as binary. PING/PONG filtered automatically by ZealPHP.
$app->ws('/ws/binary',
onMessage: function($server, $frame, $g) {
if ($frame->opcode === \OpenSwoole\WebSocket\Server::WEBSOCKET_OPCODE_BINARY) {
// Echo raw bytes back as a binary frame
$server->push($frame->fd, $frame->data, \OpenSwoole\WebSocket\Server::WEBSOCKET_OPCODE_BINARY);
} else {
$server->push($frame->fd, json_encode(['bytes' => strlen($frame->data)]));
}
}
);
Scaling past one server
Everything above lives in one OpenSwoole process tree. Run two ZealPHP instances behind a load balancer and their Store client registries are completely independent — a broadcast in process A doesn’t reach clients connected to process B. As of v0.2.39, ZealPHP ships a first-class pub/sub primitive for the cross-server routing fabric:
// app.php — flip Store to Redis. ALL nodes share the same view.
Store::defaultBackend(Store::BACKEND_REDIS);
// Track which server owns each client (cross-node visible).
Store::make('ws_owner', 4096, [
'server_id' => [Store::TYPE_STRING, 32],
]);
$myServerId = gethostname() . ':' . getmypid();
// Subscribe to YOUR identity channel — only THIS server's subscriber
// receives messages routed here.
App::subscribe("ws:server:$myServerId", function (string $payload) use ($server, $localFds) {
$msg = json_decode($payload, true);
$fd = $localFds[$msg['client_id']] ?? null;
if ($fd !== null && $server->isEstablished($fd)) {
$server->push($fd, $msg['data']);
}
});
// Anywhere — send to a specific client by id, on ANY server.
$owner = Store::get('ws_owner', $clientId, 'server_id');
Store::publish("ws:server:$owner", json_encode([
'client_id' => $clientId,
'data' => json_encode($message),
]));
// Or broadcast to a room across all servers:
App::subscribe('chat:room:42', function (string $payload) use ($server, $roomMembers) {
foreach ($roomMembers as $fd) {
if ($server->isEstablished($fd)) { $server->push($fd, $payload); }
}
});
Store::publish('chat:room:42', json_encode(['user' => 'alice', 'text' => 'hi']));
The OpenSwoole $fd is process-local — only the server that accepted the TCP connection can push to it. Redis is the routing fabric: PUBLISH carries the message to the owning server, owner's subscriber translates client_id → local fd → $server->push(). ~0.5ms loopback, sub-millisecond cross-region (validated in the pub/sub spike). Scales to N servers symmetrically — no peer-to-peer state, every routing decision is one Redis lookup + PUBLISH.
For at-least-once delivery (work queues, command/event sourcing), pair with Store::publishReliable + App::subscribeReliable — Redis Streams with consumer groups. See /store#pubsub + the deeper walkthrough at /learn/websocket#cross-server-routing.
Cross-node fan-out (roadmap). A room broadcast today reaches every worker on every node (one cluster-wide ws:room:* subscriber per worker) — O(workers×nodes) per message, even on nodes with no members. That’s being reduced toward O(nodes): the first step has landed — a per-room server-set (WSRouter::roomServers(), maintained race-free via atomic Store::eval() Lua) that tracks which nodes hold members; targeted routing and a per-node aggregator are opt-in increments. Additive groundwork only — routing is unchanged today. See the design & rollout plan.
Browser JavaScript
const endpoint = '/ws/echo';
const scheme = location.protocol === 'https:' ? 'wss://' : 'ws://';
const socket = new WebSocket(scheme + location.host + endpoint);
socket.addEventListener('open', () => {
socket.send('Hello from the browser');
});
socket.addEventListener('message', event => {
console.log('received:', event.data);
});
function sendWhenReady(message) {
if (socket.readyState === WebSocket.OPEN) {
socket.send(message);
return;
}
socket.addEventListener('open', () => socket.send(message), { once: true });
}