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

WebSocket — persistent connections for when the server needs to talk first.

You will learn

  • Why HTTP can't push updates to the browser
  • Register a WebSocket route with App::ws()
  • Authenticate connections and broadcast to specific users
  • When to use htmx vs SSE vs WebSocket vs Pub/Sub

The problem

Open your notes app in two browser tabs. Add a note in one. The other tab shows stale data until you manually refresh.

This happens because HTTP is request-response: the server only talks when the client asks. There's no way for the server to say "hey, something changed" without the client explicitly asking "did anything change?"

graph LR
    subgraph "Tab A"
      A1[Create note] -->|htmx POST| API
    end
    API -->|WS::broadcast| WS[WebSocket Server]
    subgraph "Tab B"
      WS -->|push| B1[note_changed]
      B1 -->|htmx.ajax| B2[Refresh notes list]
      B2 --> B3["Green glow ✓"]
    end
    subgraph "Tab A"
      WS -->|push| A2[note_changed]
      A2 --> A3["Already in DOM — highlight ✓"]
    end
    style API fill:#fffbeb,stroke:#f59e0b
    style WS fill:#f5f3ff,stroke:#a855f7
    style B3 fill:#ecfdf5,stroke:#059669
    style A3 fill:#ecfdf5,stroke:#059669

The mental model

In Lesson 9, you used SSE to stream AI tokens. SSE is like a one-way phone call — the server talks, you listen. But SSE can't handle the case where the client needs to send messages back, or where the server needs to push updates at any time.

WebSocket is like a walkie-talkie. Both sides can talk whenever they want. The connection stays open. This is why it works for cross-tab sync: when tab A changes something, the server pushes an update to every open walkie-talkie belonging to that user.

The handler

A WebSocket route has three callbacks — same file, same framework as HTTP routes:

$app->ws('/ws/learn',
    onMessage: function ($server, $frame) {
        if ($frame->data === 'ping') {
            $server->push($frame->fd, 'pong');
        }
    },
    onOpen: function ($server, $request) {
        $g = G::instance();
        $userId = (int) ($g->session['user_id'] ?? 0);
        if (!$userId) {
            $server->disconnect($request->fd, 1008, 'auth_required');
            return;
        }
        Store::set('learn_ws_clients', (string) $request->fd, [
            'user_id' => $userId,
        ]);
    },
    onClose: function ($server, $fd) {
        Store::del('learn_ws_clients', (string) $fd);
    },
);

Broadcasting

WebSocket connections live on individual workers. To broadcast to all of a user's tabs, iterate a shared Store table that maps fd → user_id:

// src/Learn/WS.php
public static function broadcast(int $userId, array $payload): void
{
    $server = App::getServer();
    $json = json_encode($payload);
    foreach (Store::table('learn_ws_clients') as $fd => $row) {
        if ((int) $row['user_id'] === $userId) {
            $server->push((int) $fd, $json);
        }
    }
}

Call this from any endpoint — HTTP route, SSE stream, task worker. When a note is created or deleted, the endpoint calls WS::broadcast($userId, ['type' => 'note_changed']), and every open tab refreshes its notes list via htmx.ajax().

Try it now

Open your notes in two browser tabs. Create a note in one tab — the other tab updates instantly with a green glow. Delete a note — it fades out in both tabs. Then visit AI Chat and ask the agent to create a note: watch the Event Log show SSE tool events, then the notes panel updates via WS broadcast.

The client

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

ws.addEventListener('message', (ev) => {
    const msg = JSON.parse(ev.data);
    if (msg.type === 'note_changed') {
        htmx.ajax('GET', '/api/learn/notes', {
            target: '#notes-list', swap: 'innerHTML'
        });
    }
});

ws.addEventListener('close', (ev) => {
    if (ev.code === 1008) return; // auth rejected — don't retry
    setTimeout(connect, Math.min(delay *= 2, 10000));
});

When to use what

Tool Direction When
htmxRequest → responseUser-initiated actions. 95% of web apps.
SSEServer → clientStreaming responses: AI tokens, live logs, progress bars.
WebSocketBidirectionalReal-time sync: chat, collaborative editing, live dashboards.
Pub/SubServer → serverMulti-server broadcast. Redis or RabbitMQ when you outgrow one box.
🔎 When you need a message broker

ZealPHP's WebSocket + Store is a single-server solution. The Store table lives in shared memory across workers on the same process. This breaks when you scale horizontally — multiple processes on different machines. A WebSocket client on server A can't receive pushes from server B.

One server? App::ws() + Store. Done.
Multiple servers? Add Redis Pub/Sub as the fan-out layer.
Durable delivery? RabbitMQ or Kafka — messages survive restarts.

Key Takeaways

  • HTTP can't push — WebSocket keeps a persistent bidirectional connection
  • App::ws() gives you onOpen/onMessage/onClose callbacks
  • Store tables share state across workers for broadcasting to specific users
  • Use htmx for user actions, SSE for streaming, WebSocket for real-time push, Pub/Sub for multi-server