Named Sockets
Realtime in Prisma PHP is a named socket:
pp.socket(name, args, handlers)
in the browser, and a PHP handler registered under that name on a Ratchet-backed server. You do not write a connection class, you do not open a
WebSocket yourself,
and you do not run a separate Node process.
When to reach for a socket
An RPC is a question with one answer. An RPC stream is an answer that arrives in pieces. A socket is the third shape: both sides may speak, at any time, for as long as the page is open.
Request → response. Saving a form, validating a field, loading a page of results.
One answer, delivered incrementally over SSE. Assistant output, progress logs, long exports.
Long-lived and bidirectional. Chat, presence, live feeds, collaborative state.
Enable the feature first
Select WebSocket during project creation, or enable "websocket": true in
prisma-php.json and run the project update. Until then the
src/Lib/Websocket scaffold does not exist — do not hand-roll it somewhere else.
The wire contract
Every socket in the application connects to one endpoint and names its handler in the query string:
/__pulsepoint/ws?name=<socketName>-
Arguments do not travel in the URL — a URL is logged by every proxy on the way. They arrive as the
connection's first text frame: one JSON object, exactly the payload
pp.rpcwould have posted. The client runtime sends it automatically on open. - Every frame after that is one JSON value, in either direction.
-
There is no status line inside an open connection, so failure is a frame:
{"error": "..."}— that key alone — followed by a close. The client routes it toonError, neveronMessage. - Socket names are unique application-wide. A duplicate registration is refused at startup.
/__pulsepoint/ws upgrades from the page origin to the Ratchet server
(see settings/bs-config.ts), so pp.socket("name", ...) needs no URL
configuration at all. Outside that proxy, pass the endpoint with the url option.
Client side
<script>
const [status, setStatus] = pp.state('connecting');
const [messages, setMessages] = pp.state([]);
const socket = pp.ref(null);
pp.effect(() => {
const sock = pp.socket('chat', { room: 'lobby' }, {
onOpen: () => setStatus('connected'),
onMessage: (value) => setMessages((prev) => [...prev, value]),
onError: (error) => setStatus(error.message),
onClose: ({ code, reason, wasClean }) => setStatus('disconnected'),
});
socket.current = sock;
return () => sock.close();
}, []);
function send(text) {
socket.current?.send({ text });
}
</script>
| Handle | Behaviour |
|---|---|
| send(value) | Queues until the connection opens, so calling it early is safe. Returns
false once the socket is closed. |
| close(code?, reason?) | Normal closure defaults to code 1000. |
| readyState | Mirrors WebSocket.readyState. |
new WebSocket(...) constructor for application realtime work.
It bypasses the argument frame, the origin check, the auth handshake and the error-frame convention that the rest of the stack assumes.
Server side
The scaffold lives in src/Lib/Websocket. You normally touch exactly one file in it:
sockets.php, which the server loads once at startup.
| sockets.php | Your socket registrations. This is the file you edit. |
| websocket-server.php | The Ratchet entry point and the source of truth for startup behaviour. |
| ConnectionManager.php | Framework-owned wire and lifecycle boundary: handshake security, argument frame, dispatch, limits. |
| SocketRegistry.php | The name → handler registry. |
| Socket.php | One open connection, as the handler holds it. |
| SocketPool.php | A broadcast pool of sockets. |
<?php
use Lib\Websocket\Socket;
use Lib\Websocket\SocketPool;
use Lib\Websocket\SocketRegistry;
SocketRegistry::register('echo', function (Socket $socket, array $args): void {
$label = is_string($args['label'] ?? null) ? $args['label'] : 'echo';
$socket->onMessage(function (mixed $value) use ($socket, $label): void {
$socket->send("$label: " . (is_string($value) ? $value : json_encode($value)));
});
});
// Broadcast: everyone connected to `chat` hears everyone else.
$chatRoom = new SocketPool();
SocketRegistry::register('chat', function (Socket $socket, array $args) use ($chatRoom): void {
$chatRoom->add($socket);
$socket->onMessage(fn (mixed $value) => $chatRoom->broadcast($value));
$socket->onClose(fn () => $chatRoom->discard($socket));
});
{ room: 'lobby' }, but this handler ignores
$args and broadcasts to one process-wide pool — every connection hears every message.
Passing a room argument does nothing by itself; partition it inside the handler, for example with a map of pools keyed by room name.
The Socket API
-
send(mixed $value): bool— afalsemeans the browser is gone: stop sending, do not log it as an error.trueis not delivery confirmation. Passing the reserved error shape throwsInvalidArgumentException. onMessage(callable)— each decoded JSON value the browser sends.onClose(callable)— once, when the connection closes for any reason.close()— normal closure (1000) mid-conversation.error(string $message)— the error frame, then the close. The conversation is over.payload()— the verified auth payload, ornullfor a guest.name()andisOpen().
closeWithCode(), handleMessage() and handleClose()
are @internal — they belong to ConnectionManager, not to handler code.
Auth, RBAC and limits
Authorization is declared at registration and enforced before the handler runs — not checked inside it.
SocketRegistry::register('adminFeed', $handler, requireAuth: true, allowedRoles: ['admin']);
The handshake verifies the JWT auth cookie with Auth::getInstance()->verifyToken(...) — pure JWT, no PHP
session is involved in the socket process. The cookie is located by name from AUTH_COOKIE_NAME, so that value
must match the web app's or requireAuth silently never passes.
Handshake order
Origin check (anti-CSWSH) → connection ceiling → name resolution → per-socket auth → first-frame timeout (10s).
During the connection
Message-size limit (close 1009); a rate-limit violation or an unparseable frame is an error frame then close 1008, not a recoverable warning.
The idle timeout only counts inbound frames. Socket::send(...) does not touch the
connection's last-activity time, so a push-only socket is closed after WEBSOCKET_IDLE_TIMEOUT_SECONDS
even while it is actively streaming. Have the client send a periodic heartbeat frame.
APP_ENV defaults to production when unset, so the strict origin rules apply out of the
box: the origin must appear in WEBSOCKET_ALLOWED_ORIGINS,
CORS_ALLOWED_ORIGINS or APP_BASE_URL. Development tolerates
localhost over http and an empty Origin header; production tolerates neither.
Settings
Read through PP\Env. CLI overrides
(--host=, --port=, --verbose=)
win over env, and env wins over defaults.
- WS_NAME = prisma-php-ws
- WS_VERSION = 0.0.1
- WS_HOST = 127.0.0.1
- WS_PORT = 9001
- WS_VERBOSE = true
- APP_TIMEZONE = UTC
- APP_ENV = production
- AUTH_COOKIE_NAME
- MAX_WEBSOCKET_CONNECTIONS = 200
- MAX_WEBSOCKET_MESSAGE_BYTES = 4096
- MAX_WEBSOCKET_MESSAGES_PER_WINDOW = 20
- WEBSOCKET_RATE_WINDOW_SECONDS = 10
- WEBSOCKET_IDLE_TIMEOUT_SECONDS = 120
- WEBSOCKET_ALLOWED_ORIGINS
npm run dev covers local startup. Use npm run websocket
(backed by settings/restart-websocket.ts) only to isolate or debug the socket server.