Chat App
Two files: one named socket handler that owns
the rooms, and one route that renders the conversation. No connection class, no message router, no
new WebSocket(...).
Before you start
Websocket support must be enabled in prisma-php.json so the
src/Lib/Websocket scaffold exists. See
Named Sockets for the feature flag and the wire contract.
Register the socket
The handler receives the open connection and the argument payload from the first frame. It runs once per connection, wires its callbacks, and returns — the connection stays alive on its own.
<?php
use Lib\Websocket\Socket;
use Lib\Websocket\SocketPool;
use Lib\Websocket\SocketRegistry;
/** @var array one pool per room */
$rooms = [];
SocketRegistry::register('chat', function (Socket $socket, array $args) use (&$rooms): void {
$room = is_string($args['room'] ?? null) ? $args['room'] : 'lobby';
$name = is_string($args['name'] ?? null) ? trim($args['name']) : '';
if ($name === '') {
// error frame, then close - the conversation is over
$socket->error('A display name is required.');
return;
}
$rooms[$room] ??= new SocketPool();
$pool = $rooms[$room];
$pool->add($socket);
$pool->broadcast(['id' => bin2hex(random_bytes(8)), 'text' => "$name joined."]);
$socket->onMessage(function (mixed $value) use ($pool, $name): void {
$text = is_array($value) ? trim((string) ($value['text'] ?? '')) : '';
if ($text === '') {
return;
}
$pool->broadcast([
'id' => bin2hex(random_bytes(8)),
'from' => $name,
'text' => $text,
]);
});
$socket->onClose(function () use ($pool, $socket, $name): void {
$pool->discard($socket);
$pool->broadcast(['id' => bin2hex(random_bytes(8)), 'text' => "$name left."]);
});
});
SocketPool is process-wide: every connection in it hears every message.
Rooms only exist because this handler keys a map of pools by room name — passing a
room argument does nothing on its own.
Render the route
One route root, one component script. The socket is opened in an effect with an empty dependency list, so it is created once and closed on unmount.
<?php
use PP\MainLayout;
MainLayout::$title = 'Chat';
?>
<div class="grid place-items-center min-h-screen">
<div class="w-full max-w-lg space-y-3">
<p class="text-sm text-muted-foreground">{status}</p>
<ul class="h-96 overflow-y-auto space-y-2 rounded-lg border p-4">
<template pp-for="entry in messages">
<li key="{entry.id}" class="text-sm">
<strong>{entry.from ?? 'system'}</strong> {entry.text}
</li>
</template>
</ul>
<form onsubmit="send(event)" class="flex gap-2">
<input
class="flex-1 rounded-lg border p-3"
value="{draft}"
oninput="setDraft(event.target.value)"
placeholder="Type a message..." />
<button type="submit" class="rounded-lg border px-4">Send</button>
</form>
</div>
<script>
const [status, setStatus] = pp.state('connecting...');
const [messages, setMessages] = pp.state([]);
const [draft, setDraft] = pp.state('');
const socket = pp.ref(null);
pp.effect(() => {
const sock = pp.socket('chat', { room: 'lobby', name: 'Ada' }, {
onOpen: () => setStatus('connected'),
onMessage: (entry) => setMessages((prev) => [...prev, entry]),
onError: (error) => setStatus(error.message),
onClose: () => setStatus('disconnected'),
});
socket.current = sock;
return () => sock.close();
}, []);
function send(event) {
event.preventDefault();
const text = draft.trim();
if (!text) return;
// safe before open: frames queue until the argument frame is sent
socket.current?.send({ text });
setDraft('');
}
</script>
</div>
The server owns the ids
Each broadcast carries an id so the list has a stable
key. Keying by array index degrades the diff and reuses the wrong nodes as messages arrive.
Errors are frames, not exceptions
A {"error": "..."} frame reaches
onError as an Error, never
onMessage, and the server closes right after.
Making it a private room
Declare authorization at registration. It is enforced before the handler runs, so the handler never has to check — and a refused connection gets a readable error frame instead of a silent hang.
SocketRegistry::register('teamChat', $handler, requireAuth: true, allowedRoles: ['member', 'admin']);
Inside the handler, $socket->payload() returns the verified auth payload, so the display name can come from the
session instead of a client-supplied argument — which is what you want the moment the room is not public.
Keep authenticated and guest traffic in separate pools. A single shared pool means one private broadcast can reach a guest connection, and nothing in the framework will stop it.
AUTH_COOKIE_NAME must match the web app's value, or the handshake never finds the JWT and
requireAuth refuses every connection.
What bites in production
Idle timeout counts inbound only
Socket::send(...) does not reset the idle timer. A feed that only pushes is closed after
WEBSOCKET_IDLE_TIMEOUT_SECONDS. Send a heartbeat frame from the client.
Rate limits close the connection
Exceeding MAX_WEBSOCKET_MESSAGES_PER_WINDOW, or sending an unparseable frame, is an error frame then
close 1008 — not a warning you can ignore.
send returning false is normal
It means the browser is gone. Stop sending to that socket; do not log it as an error or retry.