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.

Real-Time Sync

Open this page in two tabs. Click +1 in one. The other tab counts up too.

You will learn

  • Why HTTP can't push, and what WebSocket changes
  • The 3-callback shape: onOpen, onMessage, onClose
  • Broadcasting to many connected clients at once
  • When to use WebSocket vs SSE vs htmx vs Redis Pub/Sub

1. Overview — what you’re building

A counter that updates in every open tab the moment any tab clicks +1. No reloads, no polling, no Redis on a single server (cross-server deploys use the Redis-backed federated routing the framework ships — see the Pub/Sub bridge below). The server holds a list of open WebSocket connections; the +1 button hits a normal HTTP endpoint; that endpoint broadcasts the new value back over WebSocket to every connected tab. Try it now — open this URL in another tab first.

Build it yourself. This lesson adds WebSocket to your scaffold project. You’ll create one file:
  1. route/ws-counter.phpStore::make() call + WebSocket endpoint + broadcast helper + HTTP bump route
Add use ZealPHP\{App, Store, Counter}; at the top of the file. Call Store::make('ws_clients', 4096, ['connected_at' => [Store::TYPE_INT, 8]]) before $app->run() — the table must be allocated in the master process so all workers share it. Without this call, Store::set() silently does nothing and Store::table() returns null, so the broadcast loop’s foreach raises a Warning and pushes to nobody. Restart the server after creating the route file.
0

connected clients see this value update in real time

starting…

Open /learn/websocket in a second tab. Click +1 here. Watch the second tab count up without reloading.

2. Server setup — why HTTP can’t push

HTTP is request/response. Client asks, server answers, connection closes. The server cannot "speak first" because there’s no open socket waiting. To get a new value to a browser with plain HTTP, you have to ask repeatedly (polling) — burning bandwidth and worker-time even when nothing changed.

WebSocket upgrades the HTTP connection to a long-lived bidirectional channel. The server can push at any time. The client can send at any time. The same TCP socket carries both directions. ZealPHP’s OpenSwoole engine handles thousands of concurrent WebSocket connections per worker — coroutines (Lesson 24, Async Patterns covers them) mean each one costs ~5 KB of memory and zero worker-time while idle.

The four callbacks

sequenceDiagram
    participant B as Browser
    participant SW as OpenSwoole
Server participant H as Your callbacks participant ST as Store
ws_clients table B->>SW: HTTP Upgrade request SW->>H: onOpen(server, request) H->>ST: Store fd H-->>B: push(fd, current value) Note over B,SW: Connection is now open,
bidirectional channel B->>SW: ws.send(ping) SW->>H: onMessage(server, frame) H-->>B: push(fd, pong) Note over B: ... time passes ... B->>SW: close / disconnect SW->>H: onClose(server, fd) H->>ST: Delete fd

One call to $app->ws(), three callbacks. The callbacks handle the connection lifecycle:

use ZealPHP\{App, Store, Counter};

// Create the shared table ONCE, before $app->run() — allocated in the master
// process and shared across all workers on fork.
Store::make('ws_clients', 4096, ['connected_at' => [Store::TYPE_INT, 8]]);

// Create the shared counter ONCE, before $app->run() — it lives across all
// workers and the closures below capture it via use().
$counter = new Counter(0);

$app->ws('/ws/counter-demo',
    onMessage: function ($server, $frame) {
        // Client sent a frame. $frame->data is the payload.
        if ($frame->data === 'ping') $server->push($frame->fd, 'pong');
    },
    onOpen: function ($server, $request) use ($counter) {
        // New connection — store the fd so we can push to it later.
        Store::set('ws_clients', (string)$request->fd, ['connected_at' => time()]);
        // Send the current value to this new tab so it’s in sync immediately.
        $server->push($request->fd, json_encode(['value' => $counter->get()]));
    },
    onClose: function ($server, $fd) {
        // Client disconnected — forget the fd.
        Store::del('ws_clients', (string)$fd);
    },
);

$server is the OpenSwoole WebSocket server (same object across all callbacks). $request->fd in onOpen is the new socket’s file descriptor — an integer that’s your handle to that specific client until they disconnect. Storing every fd in a Store table is how you keep track of "who’s open" for broadcasting.

3. Broadcast patterns

To push a message to every connected client, walk your fd table and call $server->push() on each one:

// src/Broadcasts.php — helpers live in a src/ class, route files stay thin
namespace App;

use ZealPHP\{App, Store};

final class Broadcasts {
    public static function counter(int $value): void {
        $server  = App::getServer();
        $payload = json_encode(['value' => $value]);
        foreach (Store::table('ws_clients') as $fd => $_) {
            $fd = (int)$fd;
            if ($server->isEstablished($fd)) $server->push($fd, $payload);
        }
    }
}

The broadcaster is a public static function on a small src/ class (autoloaded via PSR-4), not a top-level function in the route file — route files stay thin and hot-reloadable. The call site qualifies it as Broadcasts::counter(...):

use App\Broadcasts;   // helpers live in a src/ class — route files stay thin

// In the +1 endpoint:
$app->route('/api/counter/bump', ['methods' => ['POST']], function () use ($counter) {
    $new = $counter->increment();
    Broadcasts::counter($new);
    return ['value' => $new];
});

isEstablished($fd) guards against the race where a client disconnected but their fd is still in your table because onClose hasn’t finished cleaning up. Pushing to a dead fd raises a warning; isEstablished() avoids it.

4. Client lifecycle

In the browser, WebSocket is a one-liner:

const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(proto + '//' + location.host + '/ws/counter-demo');

ws.onopen    = () => console.log('connected');
ws.onmessage = e  => {
    const msg = JSON.parse(e.data);
    document.getElementById('count').textContent = msg.value;
};
ws.onclose   = () => console.log('disconnected — reconnect after a backoff if you want');
ws.send('ping');   // round-trip test

The same ws.send() you can call any time — and the server’s onMessage picks it up. The browser’s WebSocket API has been stable since 2011; every major browser ships it. No library needed.

When to use what

WebSocket isn’t the answer to "make the UI update." Pick by data shape:

PatternDirectionBest for
htmxClient → server (request/response)Form posts, click handlers, search-as-you-type. The default.
SSE (streaming)Server → client (one-way push)AI tokens streaming, log tails, progress bars, notifications.
WebSocketBoth ways, low latencyChat, multiplayer state, collaborative editing, anything where the client also sends.
Redis Pub/SubServer ↔ server fan-outMulti-server deployments where one box's broadcast needs to reach clients on another box.

Rule of thumb: SSE for push-only, WebSocket for two-way. Reach for WebSocket only when the client also needs to send back. Don’t use WebSocket for one-way push — SSE is lighter (auto-reconnect built into EventSource, works through every proxy that handles long-lived HTTP, no upgrade handshake).

Scaling past one server: Pub/Sub bridge

Everything above lives in one process. Two ZealPHP servers behind a load balancer don’t share their ws_clients tables — a broadcast in process A doesn’t reach clients connected to process B. The fix is a shared bus that both processes subscribe to. ZealPHP v0.2.39 ships this as a first-class primitive: Store::publish + App::subscribe on the Redis backend.

// app.php — flip Store to Redis once. Counter follows automatically.
Store::defaultBackend(Store::BACKEND_REDIS);

// Each ZealPHP process registers a subscriber at boot. ZealPHP spawns the
// dedicated subscriber coroutine in onWorkerStart for you; handlers run in
// go() per message so a slow handler can't block the next read.
App::subscribe('counter:bump', function (string $payload) {
    $data = json_decode($payload, true);
    Broadcasts::counter((int) $data['value']);   // same src/ class helper
});

// In the +1 endpoint — publish instead of broadcasting directly.
$app->route('/api/counter/bump', ['methods' => ['POST']], function () use ($counter) {
    $new = $counter->increment();
    Store::publish('counter:bump', json_encode(['value' => $new]));
    return ['value' => $new];
});

Now process B’s subscriber sees the message and broadcasts to its own local ws_clients. Every connected tab sees the update, no matter which process is holding their socket. Same idea works for chat fan-out, presence, any cross-process event.

Point-to-point routing (rather than broadcast): store client_id → server_id in the same Redis-backed Store. Each server subscribes to its identity channel (ws:server:{ID}). To message client X:

// Anywhere — message a specific client by id.
$owner = Store::get('client_locations', $clientId, 'server');
Store::publish("ws:server:$owner", json_encode([
    'client_id' => $clientId,
    'data'      => $payload,
]));

// Each server's subscriber routes to the local fd.
App::subscribe("ws:server:{$myServerId}", function (string $payload) use ($server, $fdMap) {
    $msg = json_decode($payload, true);
    $fd = $fdMap[$msg['client_id']] ?? null;
    if ($fd !== null && $server->isEstablished($fd)) {
        $server->push($fd, $msg['data']);
    }
});

ZealPHP’s WebSocket fd is process-local — only the owning server can push to it. Redis is the routing fabric that says “hey, owner: here’s something to push.” Sub-millisecond loopback, ~ms cross-region. See /store#pubsub for the at-least-once publishReliable variant (Redis Streams) when drops aren’t acceptable.

You want every connected client to see live notifications. The client never sends anything back to the server. Which primitive fits best?

Key Takeaways

  • HTTP is request/response; WebSocket upgrades it to bidirectional long-lived.
  • $app->ws($path, onMessage, onOpen, onClose) — three callbacks handle the lifecycle.
  • Track open fds in Store so you can broadcast; isEstablished($fd) guards against races.
  • SSE for push-only, WebSocket for two-way. Don't use WebSocket when SSE would do.
  • Scale past one server with Store::publish + App::subscribe on the Redis backend — each process subscribes and re-broadcasts to its local ws_clients. Store::publishReliable for at-least-once via Redis Streams.