Alpha ZealPHP is early-stage and under active development. APIs may change between minor versions until v1.0. Feedback and bug reports welcome on GitHub.

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.

WebSocket registration
$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

Echo
Broadcast
Ticker
Rooms
Auth
Binary

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:

Cross-server WebSocket routing — Store::publish + App::subscribe
// 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

HTTPS-aware browser client
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 });
}

Live browser client

Disconnected
/ws/echo 0 sent / 0 received
Choose an endpoint and connect. Send will auto-connect if needed.