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.

Multi-Room Group Chat

A real chat app — multi-room, persistent history, presence — using nothing but PHP and SQLite. No Redis. No Node. No Docker.

You will learn

  • How to model rooms + messages in SQLite (the bundled-with-PHP database)
  • The WebSocket handler pattern for join → broadcast → persist → replay
  • Per-room fan-out via a shared Store table — works across all workers on one server
  • How to upgrade the same chat to N servers by swapping one fan-out helper

Why this matters

Real chat apps have rooms and history. Slack’s #general, Discord’s server channels, your team’s WhatsApp groups — they all share the same shape: many rooms, many users per room, messages persist across reloads, presence shows who’s here right now. Every real chat product is this pattern.

In Lesson 19 you built a real-time counter that broadcast +1 to every open tab — it worked while you were connected, but a reload wiped the state. In Lesson 21 you shared per-board state through OpenSwoole\Table. This lesson puts the two ideas together: rooms with persistent history. The whole stack is PHP + SQLite. SQLite ships inside PHP — you don’t install anything; it’s already there.

What you’ll build today. A pure-PHP chat that runs on a single php app.php process. Users open a tab, pick a username, join a room (#general / #engineering / whatever they type), see the room’s last 50 messages instantly, send new ones, watch other users join + leave in real time. Refresh the page — history persists. The next lesson shows how to scale this chat to N servers by swapping one helper. Same code, federated.

The data model — one SQLite table

ZealPHP’s learn lessons already use SQLite (Lesson 18 stores notes). We piggyback on the same database file (storage/learn.db) and add one table:

CREATE TABLE chatroom_messages (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    room       TEXT NOT NULL,
    username   TEXT NOT NULL,
    body       TEXT NOT NULL,
    kind       TEXT NOT NULL DEFAULT 'message',  -- 'message' or 'system' (join/leave)
    created_at INTEGER NOT NULL
);
CREATE INDEX idx_chatroom_room_time ON chatroom_messages(room, created_at);

That’s the schema. Rooms aren’t a separate table — a room is just a value in the room column. SQLite’s composite index gives O(log n) lookup “last N messages in this room”. For a small chat (thousands of rooms, millions of messages) this single-table model is plenty.

A small model class — three pure-PHP functions

Everything the chat does to SQLite collapses to three methods. This is ZealPHP\Learn\Chatroom (already in vendor/ via the framework):

final class Chatroom
{
    public static function saveMessage(string $room, string $user, string $body, string $kind = 'message'): array
    {
        $db = DB::open();
        $now = time();
        $stmt = $db->prepare(
            'INSERT INTO chatroom_messages (room, username, body, kind, created_at) VALUES (?, ?, ?, ?, ?)'
        );
        $stmt->execute([$room, $user, $body, $kind, $now]);
        return ['id' => (int)$db->lastInsertId(), 'room' => $room, 'username' => $user,
                'body' => $body, 'kind' => $kind, 'created_at' => $now];
    }

    public static function recent(string $room, int $tail = 50): array
    {
        $db = DB::open();
        $stmt = $db->prepare(
            'SELECT id, room, username, body, kind, created_at FROM chatroom_messages
             WHERE room = ? ORDER BY id DESC LIMIT ?'
        );
        $stmt->bindValue(1, $room, PDO::PARAM_STR);
        $stmt->bindValue(2, $tail, PDO::PARAM_INT);
        $stmt->execute();
        return array_reverse($stmt->fetchAll());   // chronological order
    }

    public static function listRooms(): array
    {
        $db = DB::open();
        $stmt = $db->query(
            'SELECT room, MAX(created_at) AS last_msg_at, COUNT(*) AS count
             FROM chatroom_messages GROUP BY room ORDER BY last_msg_at DESC'
        );
        return $stmt->fetchAll();
    }
}

That’s the whole persistence layer. No ORM, no framework magic — PDO::prepare + parameter binding handles every interaction. Three methods cover the three reads/writes a chat needs: save a message, recall the last N in a room, list all active rooms.

The WebSocket handler — the entire interactive layer

The chat’s real-time half lives in one App::ws() registration. Three event types: join, message, leave. The handler keeps the fd→room map in a shared Store table — not a worker-local array — so any worker can fan out to any connection. (OpenSwoole runs multiple workers by default; a worker-local array is invisible to every other worker, silently breaking cross-worker fan-out.)

use ZealPHP\Learn\Chatroom;   // model + fan-out helper live in a src/ class
use ZealPHP\Store;

// Cluster-wide fd map — must be created BEFORE App::run() forks workers.
// Route files load at boot time, so this is the right place.
Store::make('chatroom_fds', 4096, [
    'room'     => [Store::TYPE_STRING, 64],
    'username' => [Store::TYPE_STRING, 64],
]);

$app->ws('/ws/learn/chatroom', function ($server, $frame) {
    $msg  = json_decode((string) $frame->data, true);
    if (!is_array($msg)) { return; }
    $type = is_string($msg['type'] ?? null) ? $msg['type'] : '';

    if ($type === 'join') {
        $room     = is_string($msg['room'] ?? null)     ? $msg['room']     : 'general';
        $username = is_string($msg['username'] ?? null) ? $msg['username'] : 'anonymous';

        // Record membership in shared memory (visible to all workers).
        Store::set('chatroom_fds', (string) $frame->fd, [
            'room'     => $room,
            'username' => $username,
        ]);

        // Send history to the joining client only.
        $server->push($frame->fd, (string) json_encode([
            'type'  => 'history',
            'room'  => $room,
            'items' => Chatroom::recent($room, 50),
        ]));

        // Persist + broadcast a system "X joined" line to everyone in the room.
        $sys = Chatroom::saveMessage($room, $username, "joined #{$room}", 'system');
        Chatroom::broadcast_to_room($server, $room, ['type' => 'message', 'message' => $sys]);
        return;
    }

    if ($type === 'message') {
        $meta = Store::get('chatroom_fds', (string) $frame->fd);
        if (!is_array($meta)) { return; }
        $body = is_string($msg['body'] ?? null) ? $msg['body'] : '';
        if (trim($body) === '') { return; }
        $row = Chatroom::saveMessage((string) $meta['room'], (string) $meta['username'], $body);
        Chatroom::broadcast_to_room($server, (string) $meta['room'], ['type' => 'message', 'message' => $row]);
        return;
    }
}, onClose: function ($server, $fd) {
    $meta = Store::get('chatroom_fds', (string) $fd);
    Store::del('chatroom_fds', (string) $fd);
    if (!is_array($meta)) { return; }
    $sys = Chatroom::saveMessage((string) $meta['room'], (string) $meta['username'], "left #{$meta['room']}", 'system');
    Chatroom::broadcast_to_room($server, (string) $meta['room'], ['type' => 'message', 'message' => $sys]);
});

The fan-out helper isn’t a top-level function in the route file — route files stay thin. It lives as a public static function on the same ZealPHP\Learn\Chatroom model class (autoloaded via PSR-4), so the handler calls it as Chatroom::broadcast_to_room(...):

// src/Learn/Chatroom.php — helpers live in a src/ class, not the route file.
final class Chatroom
{
    // ... saveMessage() / recent() / listRooms() above ...

    /**
     * Fan-out: iterate the cluster-wide fd map and push to every fd in the room.
     * Works across workers (any worker can $server->push any fd) and across the
     * cluster when the Store backend is Redis — federated chat for free.
     *
     * @param array<string, mixed> $payload
     */
    public static function broadcast_to_room($server, string $room, array $payload, int $excludeFd = 0): void
    {
        $data = (string) json_encode($payload);
        foreach (Store::iterate('chatroom_fds') as $fd => $info) {
            if (($info['room'] ?? null) !== $room) { continue; }
            $fdInt = (int) $fd;
            if ($fdInt === $excludeFd) { continue; }
            if ($server->isEstablished($fdInt)) {
                $server->push($fdInt, $data);
            }
        }
    }
}

What just happened. The key insight is Store::make before App::run(): the table lives in OpenSwoole\Table shared memory, so every worker sees every fd’s room membership. Chatroom::broadcast_to_room() iterates the whole table and pushes to matching fds — any worker can push to any fd. ~70 lines of PHP total.

Tiny REST sidekick — the lobby + initial paint

Two GET endpoints power the room list + initial paint (so the page renders quickly even before the WS opens):

$app->route('/api/learn/chatroom/lobby',
    fn() => ['ok' => true, 'rooms' => Chatroom::listRooms()],
);

$app->route('/api/learn/chatroom/recent',
    fn($request) => [
        'ok' => true,
        'room' => $request->get['room'] ?? 'general',
        'items' => Chatroom::recent($request->get['room'] ?? 'general', 50),
    ],
);

The UI — htmx + vanilla JS

The front-end is straightforward: a room picker, an input field, a messages list. htmx handles initial paint via hx-get; a small JS opens the WS and appends new messages on receive. ZealPHP’s site uses this same pattern in template/components/_chatroom_widget.php — one file, no build step, no framework. The framework already wires htmx + persistent assets across navigations, so this lesson’s widget is just markup + a tiny <script>.

Typing indicator — ephemeral presence in 3 small parts

alice is typing…” needs three pieces. None of them touch SQLite — typing is presence, not history. It lives only while the WebSocket is open.

1. Client: debounced send on input

Each keystroke schedules a typing: 'on' frame (sent once, deduped); after 2.5 s of inactivity OR an empty input OR a sent message, send typing: 'off'.

// Single 'on' burst, refreshed every keystroke; 'off' on idle / empty / send.
const TYPING_IDLE_MS = 2500;
let lastSent = 'off';
let idleTimer = null;

function sendTyping(state) {
    if (lastSent === state) return;        // dedup repeats
    lastSent = state;
    ws.send(JSON.stringify({ type: 'typing', state }));
}

body.addEventListener('input', () => {
    if (body.value.length === 0) { sendTyping('off'); clearTimeout(idleTimer); return; }
    sendTyping('on');
    clearTimeout(idleTimer);
    idleTimer = setTimeout(() => sendTyping('off'), TYPING_IDLE_MS);
});
form.addEventListener('submit', () => { sendTyping('off'); clearTimeout(idleTimer); /* …send msg… */ });

2. Server: ephemeral fan-out (no SQLite, skip sender)

Treat typing like a message except: don’t persist, and don’t echo to the sender. The excludeFd parameter on Chatroom::broadcast_to_room() does the latter.

if ($type === 'typing') {
    $meta = Store::get('chatroom_fds', (string) $frame->fd);
    if (!is_array($meta)) { return; }
    $state = ($msg['state'] ?? '') === 'on' ? 'on' : 'off';
    Chatroom::broadcast_to_room(
        $server,
        (string) $meta['room'],
        ['type' => 'typing', 'user' => (string) $meta['username'], 'state' => $state],
        excludeFd: (int) $frame->fd,   // skip the sender's own echo
    );
    return;
}

3. Client: per-user state map + auto-clear timeout

Track a per-user typing flag with a watchdog timeout (4 s) so a dropped off frame doesn’t leave “alice is typing…” on the screen forever. Render comma-joined names.

const TYPING_TIMEOUT_MS = 4000;
const typingUsers = Object.create(null);

function handleTypingEvent(user, state) {
    if (!user || user === selfUsername) return;     // ignore self-echoes (server already skipped)
    clearTimeout(typingUsers[user]);
    if (state === 'on') {
        typingUsers[user] = setTimeout(() => { delete typingUsers[user]; renderTyping(); }, TYPING_TIMEOUT_MS);
    } else {
        delete typingUsers[user];
    }
    renderTyping();
}

function renderTyping() {
    const names = Object.keys(typingUsers);
    typingIndicator.textContent =
        names.length === 0 ? '' :
        names.length === 1 ? `${names[0]} is typing…` :
        names.length === 2 ? `${names[0]} and ${names[1]} are typing…` :
        `${names.length} people are typing…`;
}

Why this scales. The whole thing runs on the existing WebSocket — no extra connection, no extra Redis key, no extra SQLite row. Typing events are the only cluster-wide thing that’s OK to lose (a dropped “off” clears on the 4-second watchdog), so we don’t need at-least-once delivery. Goes federated automatically on the Redis backend — same one-line swap as message broadcast.

Try it — right here

The widget below uses your logged-in username from the same session that powers the Personal Notes + Tic-Tac-Toe lessons. Same auth, no separate sign-in. Type a room name (or pick one from the lobby), click Join, send messages. Open the popout in a second tab to chat with yourself; open it in a friend’s browser to chat across the network.

API surface (for hacking)

  • GET /api/learn/chatroom/lobby — what rooms exist right now
  • GET /api/learn/chatroom/recent?room=general — last 50 messages in #general
  • ws://<host>:8080/ws/learn/chatroom — the WebSocket endpoint (frames described above)

Going multi-server — one swap, federated chat

Everything above works on ONE php app.php process. To go to N servers, the only thing that has to change is the fan-out:

Single-server (this lesson)

// Default: OpenSwoole\Table — in-process shared memory.
Store::make('chatroom_fds', 4096, [...]);
// onMessage handler:
Store::set('chatroom_fds', (string) $frame->fd, ['room' => $room, ...]);
Chatroom::broadcast_to_room($server, $room, $payload);

Shared memory table; all workers on one server see every fd. Zero extra infrastructure.

Multi-server (Lesson 23)

Store::defaultBackend(Store::BACKEND_REDIS);
WSRouter::init();
// onMessage handler:
$room = WSRouter::room('chat.' . $name);  // room names allow [A-Za-z0-9_.-] — no ':' (#247)
$room->join($username);
$room->push($payload);

Cluster-wide membership + pub/sub fan-out. Same handler shape; different fabric.

The chat persists to SQLite either way — that’s the durable layer. Redis only enters the picture when you have multiple php app.php processes that need to share live state. Pure-PHP-and-SQLite covers a remarkable fraction of real apps; you can postpone Redis until you have a reason. Lesson 23 shows the upgrade in detail.

Key takeaways

  • Chat is small. Multi-room with persistence + presence is about 100 lines of PHP + one SQLite table. The framework isn’t hiding work; it’s just modest in scope.
  • SQLite is real. One file on disk, ACID, zero setup. Millions of rows per room is fine. Move to Postgres when you need multi-writer; until then, save yourself the operational cost.
  • The WebSocket handler is the whole interactive layer. Three event types (join/message/leave), one shared Store table, one fan-out helper. That’s the entire pattern.
  • Federation is one swap. Same code, switch the Store backend from Store::BACKEND_TABLE (shared memory) to Store::BACKEND_REDIS and use WSRouter::room() for pub/sub fan-out. Lesson 23 covers this.

Source on disk: model at src/Learn/Chatroom.php, WS handler at route/learn_chatroom.php. Live entrypoint at /api/learn/chatroom/lobby — explore the room list, hit /api/learn/chatroom/recent for any room’s history, open the WebSocket at /ws/learn/chatroom to chat.