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
Storetable — 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.
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 nowGET /api/learn/chatroom/recent?room=general— last 50 messages in#generalws://<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
Storetable, 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) toStore::BACKEND_REDISand useWSRouter::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.