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 worker-local fd maps — no Redis required 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 src/Learn/Chatroom.php shipped with the demo:

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 a worker-local fd map to know who’s in which room, so it can fan out to just the relevant connections:

$roomFds = [];     // room → [fd → true]
$fdMeta  = [];     // fd → {room, username}

$app->ws('/ws/learn/chatroom',
    function ($server, $frame) use (&$roomFds, &$fdMeta) {
        $msg = json_decode($frame->data, true);

        if ($msg['type'] === 'join') {
            $room = $msg['room'] ?? 'general';
            $user = $msg['username'] ?? 'anonymous';
            $fdMeta[$frame->fd]       = ['room' => $room, 'username' => $user];
            $roomFds[$room][$frame->fd] = true;

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

            // Announce the join to everyone in the room.
            $sys = Chatroom::saveMessage($room, $user, "joined #{$room}", 'system');
            broadcast_to_room($server, $roomFds, $room, ['type' => 'message', 'message' => $sys]);

        } elseif ($msg['type'] === 'message') {
            $meta = $fdMeta[$frame->fd];
            $row = Chatroom::saveMessage($meta['room'], $meta['username'], $msg['body']);
            broadcast_to_room($server, $roomFds, $meta['room'], ['type' => 'message', 'message' => $row]);
        }
    },
    onClose: function ($server, $fd) use (&$roomFds, &$fdMeta) {
        if (isset($fdMeta[$fd])) {
            $meta = $fdMeta[$fd];
            unset($roomFds[$meta['room']][$fd], $fdMeta[$fd]);
            $sys = Chatroom::saveMessage($meta['room'], $meta['username'], "left #{$meta['room']}", 'system');
            broadcast_to_room($server, $roomFds, $meta['room'], ['type' => 'message', 'message' => $sys]);
        }
    },
);

function broadcast_to_room($server, &$roomFds, string $room, array $payload): void
{
    if (!isset($roomFds[$room])) return;
    $data = json_encode($payload);
    foreach (array_keys($roomFds[$room]) as $fd) {
        if ($server->isEstablished($fd)) {
            $server->push($fd, $data);
        }
    }
}

What just happened. Five components — a handler, two state arrays, a fan-out helper, and a model. ~70 lines of PHP total. A working multi-room chat with persistence.

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 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';
    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)

$roomFds = [];
// onMessage handler:
$roomFds[$room][$fd] = true;
broadcast_to_room($server, $roomFds, $room, $payload);

Local fd map; push directly. Zero infrastructure.

Multi-server (Lesson 23)

Store::defaultBackend(Store::BACKEND_REDIS);
WSRouter::init();
// onMessage handler:
$room = WSRouter::room('chat:' . $name);
$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 fan-out helper, two state arrays. That’s the entire pattern.
  • Federation is one swap. Same code, switch the fan-out from a local fd map to WSRouter::room(). 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.