Swoole

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

PHP was built around the request that starts, answers and dies. A WebSocket server is the opposite: one process stays alive for days, holds thousands of sockets and keeps their state in memory. Swoole is the PHP extension that makes that shape possible. It brings an event loop, coroutines, a multi-process server with a built-in WebSocket protocol implementation, shared-memory data structures and timers, written in C and exposed as ordinary PHP classes. This page builds the Chatterbox chat protocol on it, shows the pieces a game loop needs, and lists the traps that are specific to a stateful PHP process.

The page was written against Swoole 6.2.3 (the current stable release on 2026-10-24; 6.3.0 is only a release candidate) on PHP 8.4.27 in the official phpswoole/swoole:6.2.3-php8.4 Docker image, OpenSwoole 26.2.0, Hyperf 3.2 and Laravel 13, checked on 2026-10-24. Everything on this page that runs was run on that image.

Swoole serves two of the three scenarios of this section. Chatterbox, the chat application, is implemented in full below with the shared wire protocol of Message Design and Serialization. Pixel Arena, the multiplayer game, gets the fixed-rate tick loop that its authoritative server needs. Huddle, the videoconference, only needs a signalling channel, and any stack of this section can supply one; Swoole adds nothing that WebRTC itself needs.

Where Swoole fits

Two earlier pages of this site describe the same extension from the Laravel side: Octane and Performance shows Swoole as one of three Octane drivers and Broadcasting and Reverb covers the ReactPHP-based Reverb server. This page is the other path: using Swoole directly, without a framework, as the WebSocket server itself.

Use Swoole for WebSockets when the team already writes PHP and wants one process model for HTTP, WebSocket and timers, when connection state must live in shared memory instead of a database, or when a game tick must run next to the sockets. Choose Reverb (Laravel Reverb) when the application is a Laravel app that already broadcasts events through Echo, and one of the stacks in Choosing a WebSocket Stack when PHP is not a requirement.

Installation

Swoole is a PHP extension (a shared library), not a Composer package. There are two supported routes: build it with PECL, or use the official Docker image, which has the extension compiled in.

# PECL: asks which optional features to build (OpenSSL, HTTP/2, curl hooks, ...)
pecl install swoole
echo "extension=swoole.so" > "$(php --ini | sed -n 's/^Scan for additional .ini files in: //p')/20-swoole.ini"

php --ri swoole     # prints the version and the enabled features

The image used for every example on this page, pinned to the exact version, and a Compose file that runs the Chatterbox server on host port 8220:

name: chatterbox-swoole
services:
  chatterbox:
    image: phpswoole/swoole:6.2.3-php8.4
    working_dir: /app
    command: php server.php
    volumes: ["./app:/app:ro"]
    ports: ["8220:9501"]
    environment:
      ORIGINS: "https://chat.example.test"
      HEARTBEAT_MS: "20000"

The extension is loaded for every PHP script of that image, so php --ri swoole reports Version => 6.2.3 there. Swoole 6 also offers a thread mode (Swoole\Thread), which needs a thread-safe (ZTS) PHP built with --enable-swoole-thread; the standard image is not ZTS, Swoole\Thread does not exist in it, and the process model below is what this page uses.

The process model

A Swoole server is not one process. Understanding which process runs which code is the key to every state question that follows.

flowchart LR C1[Browser A] --> R[Reactor threads in the master process] C2[Browser B] --> R R -->|frames, fixed by fd| W0[Worker 0] R -->|frames, fixed by fd| W1[Worker 1] W0 --> T[Shared memory: Swoole Table, Atomic, Lock] W1 --> T W0 -.->|task| TW[Task workers] M[Manager process] -.->|restarts| W0 M -.->|restarts| W1 M -.->|restarts| U[User process, for example a Redis subscriber]
  • The master process owns the listening socket and the reactor threads. They accept connections, read bytes, parse the WebSocket framing and forward complete frames to a worker. Slow or malicious clients are buffered here.

  • Worker processes (worker_num) run your PHP callbacks. Each worker is a separate PHP process with its own memory. Nothing in a global variable, a static property or an object property is visible to the other workers.

  • Task workers (task_worker_num) run slow jobs handed over with $server->task(). They are not needed in the example below.

  • The manager process supervises the workers and starts a replacement for a worker that crashes or reaches max_request.

  • A user process ($server->addProcess()) is a process the application adds, for instance to run a blocking subscriber. It is restarted by the manager and is not affected by reload.

  • Shared memory (Swoole\Table, Swoole\Atomic, Swoole\Lock) is the only state that every process can see without a network.

The constructor takes the run mode as its third argument, and the default matters. Since Swoole 5 the default is SWOOLE_BASE: no master-side reactor, every worker accepts and owns its connections, and a connection lives and dies with its worker. SWOOLE_PROCESS keeps all connections in the master, separates connections from workers, and survives a worker crash without dropping sockets, at the price of one more inter-process hop. The practical difference for a chat server is visibility: in SWOOLE_BASE mode only send and close work across processes. On Swoole 6.2.3 a worker’s $server->connections, getClientList() and isEstablished() see only the connections of that worker, so a fan-out loop in worker 0 never reaches a socket owned by worker 1. This page always passes SWOOLE_PROCESS, and the examples were run in that mode; with it, every worker sees every connection and can push to any of them.

With the default dispatch_mode 2 the master sends all frames of one connection to the same worker (chosen by file descriptor). That is why the example can keep per-connection state, such as the rate-limit bucket, in a plain PHP array inside the worker, and everything shared between connections in a Swoole\Table.

The WebSocket server API

Swoole\WebSocket\Server extends Swoole\Http\Server, so one listening port serves plain HTTP requests and WebSocket upgrades. The callbacks are registered with $server->on():

Callback When it runs

request

A normal HTTP request (anything that is not an upgrade). The example serves POST /ticket and GET /health here.

beforeHandshakeResponse

After Swoole accepted an upgrade request but before the 101 response is written. Lets the application add headers without taking over the whole handshake.

handshake

Replaces Swoole’s own handshake. The application must validate the request, write 101 with a correct Sec-WebSocket-Accept and call $response->end(). When this callback is set the open callback is not called.

open

The WebSocket connection is established (default handshake only).

message

One complete data frame arrived. $frame->opcode tells text from binary (and ping, pong or close when those are enabled); $frame->data is the payload.

close

The TCP connection ended, for any reason: client close, server disconnect, heartbeat timeout or network loss.

The server methods used by the example, with their traps:

  • $server->push($fd, $data, $opcode = WEBSOCKET_OPCODE_TEXT, $flags = SWOOLE_WEBSOCKET_FLAG_FIN) sends one frame. The opcodes are WEBSOCKET_OPCODE_TEXT, BINARY, PING and PONG. Pass SWOOLE_WEBSOCKET_FLAG_COMPRESS in the flags to compress one frame when compression was negotiated.

  • $server->isEstablished($fd) is true only for a connection that completed the WebSocket handshake. Its older neighbour exist($fd) is true for any TCP connection, including an HTTP client that never upgraded, and push to such an fd fails. Always test isEstablished before push.

  • $server->disconnect($fd, $code, $reason) sends a close frame and closes. The code must be 1000 or in 4000 to 4999, and the reason at most 125 bytes of UTF-8. That covers every application close code of the Chatterbox protocol (4400, 4401, 4408, 4413, 4426, 4429) and matches what a browser may send.

  • $server->connections is an iterator over connection fds and $server->stats() returns counters such as connection_num.

A custom handshake is the only way to refuse an upgrade with a real HTTP status. A rejection in open can only close an already established socket. The example needs it for the Origin allow-list and the chatterbox.v1 subprotocol, as shown in the walkthrough below.

Settings that matter for WebSockets

Setting Effect

worker_num

Number of worker processes; start with the number of CPU cores. Each one holds its own copy of the application.

dispatch_mode

2 (default) pins a connection to a worker by fd, which stateful protocols need. 1 and 3 are for stateless request-response traffic and disable the connect and close events by default.

max_connection

Upper bound on connections; defaults to the ulimit -n of the process, so raise the file-descriptor limit first.

package_max_length

Hard ceiling on a reassembled frame (default 2 MB). A larger frame closes the connection inside Swoole before any PHP runs (a 100000-byte frame against the example’s limit of 65536 ended with the abnormal close 1006, with no close code); the application still enforces its own, lower limit (16384 bytes for Chatterbox) and answers it with 4413.

open_websocket_ping_frame, open_websocket_pong_frame, open_websocket_close_frame

Deliver the control frames to the message callback instead of handling them silently. By default Swoole answers a client ping with a pong on its own; the example enables the pong frames to count them as liveness.

websocket_compression

Negotiate permessage-deflate (RFC 7692). Compression is then applied per frame with the compress flag. See the security section before turning it on.

heartbeat_check_interval, heartbeat_idle_time

Swoole’s own idle sweep. It only looks at the time of the last data from the client and closes the connection without an application close code. The example uses protocol pings and its own 4408 close code instead.

max_request

Recycle a worker after N requests; a stopgap for leaks, not a design. For a WebSocket server it is harmful, because connection state in that worker is lost (and in SWOOLE_BASE the sockets with it).

ssl_cert_file, ssl_key_file

Terminate TLS in Swoole (add SWOOLE_SSL to the socket type). Usually a reverse proxy does it instead.

log_level

Hide Swoole’s notices in production (SWOOLE_LOG_WARNING).

Coroutines, runtime hooks and pools

Swoole callbacks run inside coroutines by default (enable_coroutine). A coroutine is a lightweight task that the runtime suspends whenever it waits for input or output and resumes when the result is ready, so thousands of requests interleave inside one worker while the code reads top to bottom. Two mechanisms make existing PHP code cooperate:

  • Runtime hooks (Swoole\Runtime::enableCoroutine(), hook_flags, on by default inside a server) replace blocking PHP functions and streams with coroutine-aware versions: sleep, usleep, fsockopen, file_get_contents over HTTP, the PDO and mysqli drivers, and the stream-based phpredis calls. A $redis->get() then suspends only the calling coroutine.

  • Connection pools (Swoole\Database\RedisPool, PDOPool, Swoole\ConnectionPool) hand a connection to one coroutine at a time. A single Redis or PDO connection shared by two coroutines interleaves their replies and corrupts both, so every coroutine takes a connection from the pool, uses it and puts it back (the broadcast and nextSeq helpers below do exactly that).

The hooks do not cover everything. Calls that block inside an extension, such as a CPU-bound loop, a native library with its own socket code or the phpredis subscribe loop, stop the whole worker: every connection pinned to it freezes until the call returns. This was observed while testing the Redis fan-out below; running subscribe in a coroutine of worker 0 froze all connections of that worker, and moving it into its own user process fixed it.

Swoole Table: shared state for rooms and presence

Swoole\Table is a fixed-size hash table in shared memory, created before $server->start() so that every worker inherits it. It is the replacement for the global variable a PHP developer would reach for: it is visible to all workers, operations on a row are atomic, and no network is involved.

Rules that decide how it is used:

  • The size is fixed at creation (new Table(4096)); it cannot grow. When the table is full, set returns false and logs "unable to allocate memory". A table created with 64 rows stored only 43 of 5000 attempted rows in a test, because the usable count is lower than the nominal size. Check the return value of set and size the table with headroom.

  • Columns are typed (TYPE_INT, TYPE_FLOAT, TYPE_STRING) and every string column has a maximum byte length. A longer value is silently cut off (with a warning in the log), which would corrupt a JSON document. The example checks the size before storing and rejects the message instead.

  • Keys are strings of at most 63 bytes; a longer key triggers a warning and is cut. The example hashes room names and message ids into fixed-length keys.

  • Memory is reserved at create(): the documentation gives rows multiplied by (key size plus column sizes), times the conflict factor. $table->getMemorySize() reports the allocation (6.4 MB for the 2048-row, 16384-byte history table of the example, right after create()); measure it and the resident size of the container instead of computing by hand.

  • incr and decr are atomic and create a missing row with the column default 0 first. That makes incr a lock-free "first caller wins" claim: only one caller ever gets back 1. Swoole 6.3 adds add (insert only if absent) and cmpset, but 6.2.3 does not have them, so the idempotency code below uses incr.

  • There is no expiry and no eviction; the application removes rows (a timer sweeps old acknowledgements, a ring buffer evicts old history).

  • A Table is iterable with foreach, which is how the example finds the members of a room. That scan is linear in the number of rows; for large deployments keep a second table indexed by room.

Seven tables carry the whole chat state in the example: one-time tickets, users and their presence, room membership (one row per connection and room), the per-room sequence counter, message history, message ids, and acknowledgements for idempotency.

Timers

Swoole\Timer::tick($ms, $callback) runs a callback repeatedly, Timer::after($ms, $callback) once, and Timer::clear($id) cancels either. Timers belong to the process that created them. A timer created in workerStart of every worker runs once per worker, so work that must run once per server (the ping loop, the game tick, the sweep of old acknowledgements) is guarded with if ($workerId === 0).

A timer callback is a coroutine: it may call push or use pooled Redis connections, and a long one delays the next tick only if it blocks, not merely if it waits. Timers fire late rather than early, so a game tick measures real elapsed time instead of assuming TICK_MS (see the Pixel Arena example).

Chatterbox on Swoole

The server below implements the Chatterbox protocol of Message Design and Serialization for these types: hello, join, leave, message, typing, presence and history, answered by ack and error with per-room seq numbers, idempotent ids, the 16384-byte frame limit, the rate limit, the application close codes and the chatterbox.v1 subprotocol. edit, react and receipt follow the same dispatch-validate-store-broadcast pattern and are answered with a bad_request error here, and resume is not implemented (the hello reply always says resumed: false, so a client simply joins again with since). The server is one file, server.php, shown in consecutive pieces; the pieces in order are the whole file.

Settings and shared tables

<?php
declare(strict_types=1);

use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Table;
use Swoole\Timer;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const MAX_FRAME = 16384;      // maxFrameBytes of the Chatterbox hello reply
const KEEP_PER_ROOM = 500;    // history rows kept per room
const FANOUT_CHANNEL = 'chatterbox:fanout';

final class Chatterbox
{
    private Server $server;
    private Table $tickets;   // one-time tickets: ticket -> user, exp
    private Table $users;     // userId -> name, conns, status
    private Table $subs;      // room membership: one row per (fd, room)
    private Table $rooms;     // room -> seq counter
    private Table $history;   // room + seq -> full message envelope (JSON)
    private Table $ids;       // message id -> seq (for replyTo checks)
    private Table $acks;      // idempotency: (user, id) -> original ack
    private array $conn = []; // per-connection state of THIS worker only
    private int $heartbeatMs;
    private array $origins;
    private ?\Swoole\Database\RedisPool $pool = null;
    private string $redisHost;

    public function __construct()
    {
        $this->heartbeatMs = (int) (getenv('HEARTBEAT_MS') ?: 20000);
        $this->origins = array_filter(explode(',', getenv('ORIGINS') ?: 'https://chat.example.test'));
        $this->redisHost = getenv('REDIS_HOST') ?: '';
        $this->tickets = $this->table(1024, ['user' => [Table::TYPE_STRING, 32], 'exp' => [Table::TYPE_INT, 8]]);
        $this->users = $this->table(1024, ['name' => [Table::TYPE_STRING, 32], 'conns' => [Table::TYPE_INT, 8], 'status' => [Table::TYPE_STRING, 8]]);
        $this->subs = $this->table(4096, ['fd' => [Table::TYPE_INT, 8], 'room' => [Table::TYPE_STRING, 64], 'userId' => [Table::TYPE_STRING, 32]]);
        $this->rooms = $this->table(1024, ['seq' => [Table::TYPE_INT, 8]]);
        $this->history = $this->table(2048, ['json' => [Table::TYPE_STRING, MAX_FRAME]]);
        $this->ids = $this->table(4096, ['seq' => [Table::TYPE_INT, 8]]);
        $this->acks = $this->table(4096, ['claim' => [Table::TYPE_INT, 8], 'ack' => [Table::TYPE_STRING, 512], 'at' => [Table::TYPE_INT, 8]]);

The constructor builds every Swoole\Table before the server exists. Per-connection state ($conn) is deliberately a plain array: with dispatch_mode 2 a connection stays on one worker, so no other process ever needs it.

The server, workers and timers

        $this->server = new Server('0.0.0.0', 9501, SWOOLE_PROCESS);
        $this->server->set([
            'worker_num' => 2,
            'dispatch_mode' => 2,               // fixed: one connection stays on one worker
            'max_connection' => 10000,
            'package_max_length' => 65536,      // hard ceiling enforced by Swoole itself
            'open_websocket_pong_frame' => true, // deliver pong frames to onMessage
            'websocket_compression' => false,   // permessage-deflate stays off
            'log_level' => SWOOLE_LOG_WARNING,
        ]);
        if ($this->redisHost !== '') {
            // A blocking subscriber must not share a worker with connections: it gets its own process.
            $this->server->addProcess(new \Swoole\Process($this->subscribe(...), false, 0, false));
        }
        $this->server->on('workerStart', $this->onWorkerStart(...));
        $this->server->on('request', $this->onRequest(...));
        $this->server->on('handshake', $this->onHandshake(...));
        $this->server->on('message', $this->onMessage(...));
        $this->server->on('close', $this->onClose(...));
    }

    private function table(int $size, array $columns): Table
    {
        $t = new Table($size);
        foreach ($columns as $name => [$type, $length]) {
            $t->column($name, $type, $length);
        }
        $t->create();
        return $t;
    }

    public function start(): void
    {
        $this->server->start();
    }
    private function onWorkerStart(Server $server, int $workerId): void
    {
        if ($this->redisHost !== '') {
            $config = (new \Swoole\Database\RedisConfig())->withHost($this->redisHost)->withPort(6379);
            $this->pool = new \Swoole\Database\RedisPool($config, 16);
        }
        if ($workerId === 0) {
            // Application-level ping every heartbeatMs (browsers answer with a pong automatically).
            Timer::tick($this->heartbeatMs, function () use ($server) {
                foreach ($server->connections as $fd) {
                    if ($server->isEstablished($fd)) {
                        $server->push($fd, '', WEBSOCKET_OPCODE_PING);
                    }
                }
            });
            // Sweep idempotency rows older than 24 hours.
            Timer::tick(60000, function () {
                $limit = time() - 86400;
                foreach ($this->acks as $key => $row) {
                    if ($row['at'] < $limit) {
                        $this->acks->del($key);
                    }
                }
            });
        }
        // Every worker checks its own connections for silence (2 x heartbeatMs).
        Timer::tick($this->heartbeatMs, function () {
            $now = (int) (microtime(true) * 1000);
            foreach ($this->conn as $fd => $c) {
                if ($now - $c['seen'] > 2 * $this->heartbeatMs) {
                    $this->close($fd, 4408, 'heartbeat timeout');
                }
            }
        });
    }

worker_num is 2 so that the examples exercise cross-worker behaviour. Worker 0 pings every connection every heartbeatMs (browsers answer with a pong automatically) and sweeps acknowledgements older than 24 hours. Every worker checks its own connections for silence and closes with 4408 after two heartbeat intervals.

HTTP endpoints: one-time tickets

    private function onRequest(Request $req, Response $res): void
    {
        $res->header('Content-Type', 'application/json');
        $path = $req->server['request_uri'] ?? '/';
        $method = $req->server['request_method'] ?? 'GET';
        if ($method === 'POST' && $path === '/ticket') {
            // Demo only: a real service authenticates the session cookie first.
            $user = (string) ($req->post['user'] ?? '');
            if (!preg_match('/^[a-z0-9]{1,32}$/', $user)) {
                $res->status(400);
                $res->end('{"error":"bad_request"}');
                return;
            }
            $ticket = bin2hex(random_bytes(16));
            $this->tickets->set($ticket, ['user' => $user, 'exp' => time() + 30]);
            $res->end(json_encode(['ticket' => $ticket, 'expiresInMs' => 30000]));
            return;
        }
        if ($path === '/health') {
            $res->end(json_encode(['ok' => true, 'worker' => $this->server->getWorkerId(), 'connections' => $this->server->stats()['connection_num']]));
            return;
        }
        $res->status(404);
        $res->end('{"error":"not_found"}');
    }

POST /ticket is the only part that would be replaced in a real application: it must authenticate the user’s session first (see Authentication and Authorization) and then mint the ticket. The ticket is a random 32-character string valid for 30 seconds and consumed by del, which succeeds for exactly one caller.

The handshake: Origin allow-list and subprotocol

    private function onHandshake(Request $req, Response $res): bool
    {
        $origin = $req->header['origin'] ?? null;
        if ($origin !== null && !in_array($origin, $this->origins, true)) {
            $res->status(403);
            $res->end();
            return false;
        }
        $key = $req->header['sec-websocket-key'] ?? '';
        if (preg_match('#^[+/0-9A-Za-z]{21}[AQgw]==$#', $key) !== 1) {
            $res->status(400);
            $res->end();
            return false;
        }
        $headers = [
            'Upgrade' => 'websocket',
            'Connection' => 'Upgrade',
            'Sec-WebSocket-Accept' => base64_encode(sha1($key . WS_GUID, true)),
            'Sec-WebSocket-Version' => '13',
        ];
        $offered = array_map('trim', explode(',', $req->header['sec-websocket-protocol'] ?? ''));
        if (in_array('chatterbox.v1', $offered, true)) {
            $headers['Sec-WebSocket-Protocol'] = 'chatterbox.v1';
        }
        foreach ($headers as $name => $value) {
            $res->header($name, $value);
        }
        $res->status(101);
        $res->end();
        // A custom handshake suppresses the open event: run the open logic ourselves.
        $fd = $req->fd;
        \Swoole\Event::defer(fn () => $this->onOpen($fd));
        return true;
    }

    private function onOpen(int $fd): void
    {
        $cid = bin2hex(random_bytes(8));   // guards timers against fd reuse
        $now = microtime(true);
        $this->conn[$fd] = ['cid' => $cid, 'hello' => false, 'user' => '', 'seen' => (int) ($now * 1000),
            'tokens' => 40.0, 'refill' => $now, 'limited' => []];
        Timer::after(5000, function () use ($fd, $cid) {
            if (($this->conn[$fd]['cid'] ?? null) === $cid && !$this->conn[$fd]['hello']) {
                $this->close($fd, 4408, 'hello timeout');
            }
        });
    }

Browsers send cookies on a WebSocket handshake and do not apply the same-origin policy to it, so the server must compare the Origin header with an allow-list (Cross-Site WebSocket Hijacking otherwise). Non-browser clients send no Origin and rely on the ticket. The handler writes the 101 response itself; because that suppresses open, it schedules the open logic with Swoole\Event::defer(). The wiki page for the handshake callback suggests $server->defer(); on Swoole 6.2.3 that method does not exist on Swoole\WebSocket\Server and the call is a fatal error, while Swoole\Event::defer() works. The cid created in onOpen is a connection generation token: file descriptors are reused, so a timer created for one connection must check that the fd still belongs to the same connection before acting.

Frames, validation, rate limit and dispatch

    private function onMessage(Server $server, Frame $frame): void
    {
        $fd = $frame->fd;
        if (!isset($this->conn[$fd])) {
            return;
        }
        $this->conn[$fd]['seen'] = (int) (microtime(true) * 1000);
        if ($frame->opcode === WEBSOCKET_OPCODE_PONG) {
            return;
        }
        if ($frame->opcode !== WEBSOCKET_OPCODE_TEXT) {
            $this->close($fd, 4400, 'text frames only');
            return;
        }
        if (strlen($frame->data) > MAX_FRAME) {
            $this->close($fd, 4413, 'frame too large');
            return;
        }
        if (!$this->allow($fd)) {
            $this->error($fd, null, 'rate_limited', 'slow down', true);
            return;
        }
        $env = json_decode($frame->data, true);
        if (!is_array($env) || !isset($env['type'], $env['id'], $env['v'])) {
            $this->error($fd, null, 'bad_request', 'not a Chatterbox envelope');
            return;
        }
        if ($env['v'] !== 1) {
            $this->error($fd, null, 'unsupported_version', 'only v1 is supported');
            $this->close($fd, 4426, 'unsupported version');
            return;
        }
        $id = $env['id'];
        if (!is_string($id) || strlen($id) < 26 || strlen($id) > 36) {
            $this->error($fd, null, 'bad_request', 'id must be 26-36 characters');
            return;
        }
        $payload = $env['payload'] ?? [];
        $room = $env['room'] ?? null;
        if (!is_array($payload) || ($room !== null && (!is_string($room) || !preg_match('/^[a-z0-9-]{1,64}$/', $room)))) {
            $this->error($fd, $id, 'bad_request', 'invalid room or payload');
            return;
        }
        if ($env['type'] === 'hello') {
            $this->hello($fd, $id, $payload);
            return;
        }
        if (!$this->conn[$fd]['hello']) {
            $this->error($fd, $id, 'unauthorized', 'hello first');
            $this->close($fd, 4401, 'hello first');
            return;
        }
        try {
            $this->dispatch($fd, $id, $env['type'], $room, $payload);
        } catch (\Throwable $e) {
            // An uncaught exception would take the whole worker down with every connection on it.
            error_log('chatterbox: ' . $e);
            $this->error($fd, $id, 'internal', 'internal error', true);
        }
    }

    private function dispatch(int $fd, string $id, mixed $type, ?string $room, array $payload): void
    {
        match ($type) {
            'join' => $this->idempotent($fd, $id, fn () => $this->join($fd, $id, $room, $payload)),
            'leave' => $this->idempotent($fd, $id, fn () => $this->leave($fd, $id, $room)),
            'message' => $this->idempotent($fd, $id, fn () => $this->message($fd, $id, $room, $payload)),
            'presence' => $this->idempotent($fd, $id, fn () => $this->presence($fd, $id, $payload)),
            'history' => $this->history($fd, $id, $room, $payload),
            'typing' => $this->typing($fd, $room, $payload),
            'edit', 'react', 'receipt' => $this->error($fd, $id, 'bad_request', 'not implemented by this server'),
            default => $this->error($fd, $id, 'bad_request', 'unknown type'),
        };
    }

Every rule that can be checked without state is checked first: opcode, size, rate, JSON shape, version, id length, room syntax. A failure is an error envelope; fatal ones are followed by a close with the application code. The try block around dispatch matters on Swoole: an uncaught exception inside a callback terminates the worker process, and with it every connection it owns.

Hello, ack and idempotency

    private function hello(int $fd, string $id, array $payload): void
    {
        if ($this->conn[$fd]['hello']) {
            $this->error($fd, $id, 'conflict', 'already authenticated');
            return;
        }
        $token = $payload['token'] ?? null;
        $ticket = is_string($token) ? $this->tickets->get($token) : false;
        // del() succeeds for exactly one caller, so a ticket is consumed once.
        if ($ticket === false || !$this->tickets->del($token) || $ticket['exp'] < time()) {
            $this->error($fd, $id, 'unauthorized', 'invalid or expired ticket');
            $this->close($fd, 4401, 'unauthorized');
            return;
        }
        $user = $ticket['user'];
        if (!$this->users->exist($user)) {
            $this->users->set($user, ['name' => ucfirst($user), 'conns' => 0, 'status' => 'online']);
        }
        $this->users->incr($user, 'conns');
        $this->users->set($user, ['status' => 'online']);
        $this->conn[$fd]['hello'] = true;
        $this->conn[$fd]['user'] = $user;
        $this->send($fd, $this->envelope('hello', null, [
            'ref' => $id, 'userId' => $user, 'name' => ucfirst($user), 'sessionId' => bin2hex(random_bytes(12)),
            'heartbeatMs' => $this->heartbeatMs, 'maxFrameBytes' => MAX_FRAME, 'resumed' => false, 'rooms' => [],
        ]));
    }
    private function idempotent(int $fd, string $id, callable $work): void
    {
        $key = substr(sha1($this->conn[$fd]['user'] . '|' . $id), 0, 32);
        // incr() is atomic and creates the row: only the first caller sees 1.
        if ($this->acks->incr($key, 'claim') > 1) {
            $row = $this->acks->get($key);
            if ($row !== false && $row['ack'] !== '') {
                $ack = json_decode($row['ack'], true);
                $this->ack($fd, $id, $ack['seq'], true, $ack['result'] ?: new stdClass());
            } else {
                $this->error($fd, $id, 'unavailable', 'request in progress', true);
            }
            return;
        }
        try {
            [$seq, $result, $after] = $work();
        } catch (ChatError $e) {
            $this->acks->del($key);   // errors are not cached
            $this->error($fd, $id, $e->errorCode, $e->getMessage(), $e->retryable);
            return;
        }
        $stored = json_encode(['seq' => $seq, 'result' => $result]);
        if (strlen($stored) > 2048) {
            $stored = json_encode(['seq' => $seq, 'result' => new stdClass()]);
        }
        $this->acks->set($key, ['claim' => 1, 'ack' => $stored, 'at' => time()]);
        $this->ack($fd, $id, $seq, false, $result ?: new stdClass());
        if ($after !== null) {
            $after();   // fan-out runs after the ack is on the wire
        }
    }

idempotent() claims the key (userId, id) atomically with incr. The first caller does the work, stores the acknowledgement and sends it; any other caller finds the claim taken and replays the stored acknowledgement with dup: true. A failed request releases the claim (errors are never cached). The $after closure returned by each handler runs after the acknowledgement was written, so the sender’s ack always precedes the broadcast.

Rooms, messages, history, presence

    private function join(int $fd, string $id, ?string $room, array $p): array
    {
        $room ?? throw new ChatError('bad_request', 'room required');
        if (preg_match('/^dm-([a-z0-9]+)-([a-z0-9]+)$/', $room, $m) && !in_array($this->conn[$fd]['user'], [$m[1], $m[2]], true)) {
            throw new ChatError('forbidden', 'not a participant of this DM');
        }
        $user = $this->conn[$fd]['user'];
        $subKey = $fd . '|' . substr(md5($room), 0, 20);
        $isNew = !$this->subs->exist($subKey);
        if (!$this->subs->set($subKey, ['fd' => $fd, 'room' => $room, 'userId' => $user])) {
            throw new ChatError('unavailable', 'membership table full', true);
        }
        $lastSeq = $this->rooms->get(md5($room))['seq'] ?? 0;
        $since = $p['since'] ?? null;
        $after = function () use ($fd, $room, $user, $isNew, $since, $lastSeq) {
            if ($isNew) {
                $this->broadcast($room, $this->envelope('presence', $room, $this->presenceOf($user)), $user);
            }
            if (is_int($since)) {
                for ($s = $since + 1; $s <= $lastSeq; $s++) {
                    $row = $this->history->get(md5($room) . '.' . $s);
                    if ($row !== false) {
                        $this->server->push($fd, $row['json']);
                    }
                }
            }
        };
        return [null, ['members' => $this->membersOf($room), 'lastSeq' => $lastSeq], $after];
    }

    private function leave(int $fd, string $id, ?string $room): array
    {
        $key = $fd . '|' . substr(md5((string) $room), 0, 20);
        if ($room === null || !$this->subs->del($key)) {
            throw new ChatError('not_found', 'not a member');
        }
        return [null, [], null];
    }
    private function message(int $fd, string $id, ?string $room, array $p): array
    {
        if ($room === null || !$this->subs->exist($fd . '|' . substr(md5($room), 0, 20))) {
            throw new ChatError('forbidden', 'join the room first');
        }
        $text = $p['text'] ?? null;
        // Chatterbox counts UTF-16 code units, like JavaScript String.length.
        $units = is_string($text) ? intdiv(strlen((string) mb_convert_encoding($text, 'UTF-16LE', 'UTF-8')), 2) : 0;
        if ($units < 1 || $units > 4000) {
            throw new ChatError('bad_request', 'text must be 1..4000 characters');
        }
        $replyTo = $p['replyTo'] ?? null;
        if ($replyTo !== null && (!is_string($replyTo) || !$this->ids->exist(substr(sha1($room . '|' . $replyTo), 0, 32)))) {
            throw new ChatError('not_found', 'replyTo is not a message of this room');
        }
        $attachments = $p['attachments'] ?? [];
        if (!is_array($attachments) || count($attachments) > 5) {
            throw new ChatError('bad_request', 'at most 5 attachments');
        }
        foreach ($attachments as $a) {
            if (!is_array($a) || !is_string($a['url'] ?? null) || !str_starts_with($a['url'], 'https://')
                || !is_string($a['name'] ?? null) || !is_string($a['mime'] ?? null) || !is_int($a['size'] ?? null)) {
                throw new ChatError('bad_request', 'attachment needs https url, name, mime, size');
            }
        }
        $user = $this->conn[$fd]['user'];
        $seq = $this->nextSeq($room);
        $env = $this->envelope('message', $room, [
            'from' => ['userId' => $user, 'name' => ucfirst($user)], 'text' => $text, 'replyTo' => $replyTo,
            'attachments' => $attachments, 'seq' => $seq, 'edited' => false,
        ], $id);   // the envelope id IS the client's message id
        $json = json_encode($env, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (strlen($json) > MAX_FRAME) {
            throw new ChatError('too_large', 'message envelope too large');
        }
        // A Table silently truncates a string longer than its column, so the size is checked first.
        if (!$this->history->set(md5($room) . '.' . $seq, ['json' => $json])
            || !$this->ids->set(substr(sha1($room . '|' . $id), 0, 32), ['seq' => $seq])) {
            throw new ChatError('unavailable', 'history table full', true);
        }
        if ($seq > KEEP_PER_ROOM) {   // ring buffer: evict the oldest row and its id
            $old = $this->history->get(md5($room) . '.' . ($seq - KEEP_PER_ROOM));
            if ($old !== false) {
                $this->ids->del(substr(sha1($room . '|' . json_decode($old['json'], true)['id']), 0, 32));
                $this->history->del(md5($room) . '.' . ($seq - KEEP_PER_ROOM));
            }
        }
        return [$seq, [], fn () => $this->broadcast($room, $json)];
    }

    private function history(int $fd, string $id, ?string $room, array $p): void
    {
        if ($room === null || !$this->subs->exist($fd . '|' . substr(md5($room), 0, 20))) {
            $this->error($fd, $id, 'forbidden', 'join the room first');
            return;
        }
        $limit = max(1, min(100, (int) ($p['limit'] ?? 50)));
        $last = $this->rooms->get(md5($room))['seq'] ?? 0;
        $s = (isset($p['before']) && is_int($p['before']) ? $p['before'] : $last + 1) - 1;
        $messages = [];
        $bytes = 0;
        while ($s >= 1 && count($messages) < $limit && $bytes < 200000) {
            $row = $this->history->get(md5($room) . '.' . $s);
            if ($row === false) {
                break;   // evicted: older messages are gone
            }
            array_unshift($messages, json_decode($row['json'], true));
            $bytes += strlen($row['json']);
            $s--;
        }
        $hasMore = $s >= 1 && $this->history->exist(md5($room) . '.' . $s);
        $next = $hasMore && $messages ? $messages[0]['payload']['seq'] : null;
        $this->ack($fd, $id, null, false, ['messages' => $messages, 'hasMore' => $hasMore, 'next' => $next]);
    }
    private function presence(int $fd, string $id, array $p): array
    {
        $status = $p['status'] ?? null;
        if (!in_array($status, ['online', 'away'], true)) {
            throw new ChatError('bad_request', 'status must be online or away');
        }
        $user = $this->conn[$fd]['user'];
        $changed = $this->users->get($user)['status'] !== $status;
        $this->users->set($user, ['status' => $status]);
        $after = $changed ? function () use ($user) {
            foreach ($this->roomsOf($user) as $room) {
                $this->broadcast($room, $this->envelope('presence', $room, $this->presenceOf($user)), $user);
            }
        } : null;
        return [null, [], $after];
    }

    private function typing(int $fd, ?string $room, array $p): void
    {
        $user = $this->conn[$fd]['user'];
        if ($room !== null && in_array($p['state'] ?? '', ['start', 'stop'], true)
            && $this->subs->exist($fd . '|' . substr(md5($room), 0, 20))) {
            $this->broadcast($room, $this->envelope('typing', $room, ['userId' => $user, 'state' => $p['state']]), $user);
        }
    }

A room has no object of its own: it is a name, a sequence counter in $rooms and the set of rows in $subs. The history is a ring buffer of the last 500 messages per room. The ids table lets replyTo be validated, and its rows are evicted together with the history row they belong to; forgetting that would fill the table within hours.

Close, grace period and helpers

    private function onClose(Server $server, int $fd): void
    {
        $c = $this->conn[$fd] ?? null;
        unset($this->conn[$fd]);
        if ($c === null || !$c['hello']) {
            return;
        }
        $user = $c['user'];
        $rooms = [];
        foreach ($this->subs as $key => $row) {
            if ($row['fd'] === $fd) {
                $rooms[] = $row['room'];
                $this->subs->del($key);
            }
        }
        if ($this->users->decr($user, 'conns') <= 0) {
            // 15 s grace before "offline", so a page reload does not flap.
            Timer::after(15000, function () use ($user, $rooms) {
                if ($this->users->get($user)['conns'] <= 0) {
                    $this->users->set($user, ['status' => 'offline']);
                    foreach ($rooms as $room) {
                        $this->broadcast($room, $this->envelope('presence', $room, $this->presenceOf($user)), $user);
                    }
                }
            });
        }
    }
    private function broadcast(string $room, array|string $frame, ?string $skipUser = null): void
    {
        $json = is_string($frame) ? $frame : json_encode($frame, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($this->pool === null) {
            $this->fanout($room, $json, $skipUser);
            return;
        }
        $redis = $this->pool->get();
        try {
            $redis->publish(FANOUT_CHANNEL, json_encode(['room' => $room, 'frame' => $json, 'skip' => $skipUser]));
        } finally {
            $this->pool->put($redis);
        }
    }

    private function fanout(string $room, string $json, ?string $skipUser): void
    {
        foreach ($this->subs as $row) {
            if ($row['room'] === $room && $row['userId'] !== $skipUser && $this->server->isEstablished($row['fd'])) {
                $this->server->push($row['fd'], $json);
            }
        }
    }

    private function subscribe(\Swoole\Process $process): void
    {
        while (true) {   // reconnect forever: a subscriber connection can drop at any time
            try {
                $redis = new \Redis();   // a subscriber needs its own connection, not one from the pool
                $redis->connect($this->redisHost, 6379);
                $redis->setOption(\Redis::OPT_READ_TIMEOUT, -1);   // an idle channel is normal
                $redis->subscribe([FANOUT_CHANNEL], function ($r, $channel, $message) {
                    $m = json_decode($message, true);
                    $this->fanout($m['room'], $m['frame'], $m['skip']);
                });
            } catch (\RedisException $e) {
                error_log('chatterbox: redis subscriber lost: ' . $e->getMessage());
            }
            sleep(1);
        }
    }

    private function nextSeq(string $room): int
    {
        if ($this->pool === null) {
            return $this->rooms->incr(md5($room), 'seq');
        }
        $redis = $this->pool->get();
        try {
            $seq = (int) $redis->incr('chatterbox:seq:' . $room);
        } finally {
            $this->pool->put($redis);
        }
        $this->rooms->set(md5($room), ['seq' => $seq]);
        return $seq;
    }

    private function membersOf(string $room): array
    {
        $seen = [];
        foreach ($this->subs as $row) {
            if ($row['room'] === $room && !isset($seen[$row['userId']])) {
                $seen[$row['userId']] = $this->presenceOf($row['userId']);
            }
        }
        return array_values($seen);
    }

    private function roomsOf(string $user): array
    {
        $rooms = [];
        foreach ($this->subs as $row) {
            if ($row['userId'] === $user) {
                $rooms[$row['room']] = true;
            }
        }
        return array_keys($rooms);
    }

    private function presenceOf(string $user): array
    {
        $u = $this->users->get($user);
        return ['userId' => $user, 'name' => $u['name'], 'status' => $u['status']];
    }

    private function allow(int $fd): bool
    {
        $c = &$this->conn[$fd];
        $now = microtime(true);
        $c['tokens'] = min(40.0, $c['tokens'] + ($now - $c['refill']) * 20);
        $c['refill'] = $now;
        if ($c['tokens'] >= 1) {
            $c['tokens'] -= 1;
            return true;
        }
        $c['limited'] = array_values(array_filter($c['limited'], fn ($t) => $now - $t < 10));
        $c['limited'][] = $now;
        if (count($c['limited']) >= 10) {
            $this->close($fd, 4429, 'rate limited');
        }
        return false;
    }

    private function envelope(string $type, ?string $room, array $payload, ?string $id = null): array
    {
        return ['v' => 1, 'type' => $type, 'id' => $id ?? 's_' . $this->ulid(), 'room' => $room,
            'ts' => (int) (microtime(true) * 1000), 'payload' => $payload ?: new stdClass()];
    }

    private function ulid(): string
    {
        $alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
        $t = (int) (microtime(true) * 1000);
        $out = '';
        for ($i = 0; $i < 10; $i++) {
            $out = $alphabet[$t % 32] . $out;
            $t = intdiv($t, 32);
        }
        for ($i = 0; $i < 16; $i++) {
            $out .= $alphabet[random_int(0, 31)];
        }
        return $out;
    }

    private function send(int $fd, array|string $env): void
    {
        $json = is_string($env) ? $env : json_encode($env, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if ($this->server->isEstablished($fd)) {   // exist() only says "TCP connection"
            $this->server->push($fd, $json);
        }
    }

    private function ack(int $fd, string $ref, ?int $seq, bool $dup, array|object $result): void
    {
        $this->send($fd, $this->envelope('ack', null, ['ref' => $ref, 'ok' => true, 'seq' => $seq, 'dup' => $dup, 'result' => $result]));
    }

    private function error(int $fd, ?string $ref, string $code, string $message, bool $retryable = false): void
    {
        $this->send($fd, $this->envelope('error', null, ['ref' => $ref, 'code' => $code, 'message' => $message, 'retryable' => $retryable]));
    }

    private function close(int $fd, int $code, string $reason): void
    {
        $this->server->disconnect($fd, $code, $reason);
    }
}

final class ChatError extends RuntimeException
{
    public function __construct(public readonly string $errorCode, string $message, public readonly bool $retryable = false)
    {
        parent::__construct($message);
    }
}

(new Chatterbox())->start();

When the last connection of a user closes, the user goes offline only after a 15-second grace period, so a page reload does not flap the presence of everyone in the room. The timer closure re-checks the connection count when it fires.

Running it and checking the protocol

docker compose up -d
npm install ws@8.22.0
node scenario.mjs localhost:8220
// Node 22+ (global WebSocket) and `npm install ws@8.22.0`. Run: node scenario.mjs localhost:8220
import { randomBytes } from 'node:crypto';
import WebSocket from 'ws';

const base = process.argv[2] ?? 'localhost:8220';
const ulid = () => randomBytes(13).toString('hex').toUpperCase();   // 26 characters

async function connect(user) {
  const res = await fetch(`http://${base}/ticket`, { method: 'POST', body: new URLSearchParams({ user }) });
  const { ticket } = await res.json();
  const ws = new WebSocket(`ws://${base}/ws`, 'chatterbox.v1');
  const inbox = [];
  ws.on('message', (data) => inbox.push(JSON.parse(data.toString())));
  await new Promise((resolve) => ws.on('open', resolve));
  const send = (type, room, payload, id = ulid()) => {
    ws.send(JSON.stringify({ v: 1, type, id, room, ts: 0, payload }));
    return id;
  };
  const next = async (match) => {
    for (let i = 0; i < 100; i++) {
      const at = inbox.findIndex(match);
      if (at >= 0) return inbox.splice(at, 1)[0];
      await new Promise((resolve) => setTimeout(resolve, 20));
    }
    throw new Error('timed out');
  };
  send('hello', null, { token: ticket, client: 'scenario/1' });
  await next((e) => e.type === 'hello');
  return { ws, send, next };
}

const alice = await connect('alice');
const bob = await connect('bob');
for (const c of [alice, bob]) {
  const id = c.send('join', 'general', { since: null });
  await c.next((e) => e.type === 'ack' && e.payload.ref === id);
}

const text = { text: 'Hello, Chatterbox', replyTo: null, attachments: [] };
const id = alice.send('message', 'general', text);
const ack = await alice.next((e) => e.type === 'ack' && e.payload.ref === id);
const seen = await bob.next((e) => e.type === 'message');
console.log('ack      ', ack.payload.seq, ack.payload.dup, '| bob received seq', seen.payload.seq, 'from', seen.payload.from.userId);

alice.send('message', 'general', text, id);   // the same id again, as after a reconnect
const dup = await alice.next((e) => e.type === 'ack' && e.payload.ref === id);
console.log('duplicate', dup.payload.seq, dup.payload.dup);

const bad = alice.send('message', 'general', { text: '', replyTo: null, attachments: [] });
const err = await alice.next((e) => e.type === 'error' && e.payload.ref === bad);
console.log('error    ', err.payload.code, err.payload.retryable);

alice.ws.close();
bob.ws.close();

The output of that script against the server above:

ack       1 false | bob received seq 1 from alice
duplicate 1 true
error     bad_request false

A longer test client was used for the verification of this page. It checked, against the same server, that: a request with an Origin outside the allow-list gets HTTP 403; a replayed ticket gets unauthorized and the close code 4401; a second user joining produces a presence envelope for the first; a message produces an ack with seq 1 and a broadcast to both connections; the same id again produces dup: true with the same seq and no second broadcast; forbidden, bad_request, not_found and an unknown type each produce the right error code; history pages backwards (before / next) without gaps; join with since replays the missed messages; typing and presence: away reach the other member; a frame with v: 2 gets unsupported_version and close 4426; a 20000-byte frame closes with 4413; a binary frame closes with 4400; a socket that never says hello is closed with 4408 after about 5 seconds; and a burst of 100 envelopes produces rate_limited errors followed by close 4429. The same client was run five times against fresh servers. With HEARTBEAT_MS=1000, four out of four connections received pings.

Pixel Arena: a tick loop next to the sockets

An authoritative game server (see Multiplayer Game Architecture) advances the world at a fixed rate and broadcasts snapshots. In Swoole that is one Timer::tick in one worker, with the players in a Swoole\Table that every worker may update from its message callback.

<?php
declare(strict_types=1);

use Swoole\Atomic;
use Swoole\Table;
use Swoole\Timer;
use Swoole\WebSocket\Frame;
use Swoole\WebSocket\Server;

const TICK_MS = 50;   // 20 simulation steps per second
const SPEED = 4.0;    // world units per second

$players = new Table(256);
$players->column('x', Table::TYPE_FLOAT);
$players->column('y', Table::TYPE_FLOAT);
$players->column('dx', Table::TYPE_INT, 1);   // last input direction: -1, 0 or 1
$players->column('dy', Table::TYPE_INT, 1);
$players->create();
$tickNo = new Atomic(0);

$server = new Server('0.0.0.0', 9502, SWOOLE_PROCESS);
$server->set([
    'worker_num' => 2,
    'log_level' => SWOOLE_LOG_WARNING,
]);

$server->on('workerStart', function (Server $server, int $workerId) use ($players, $tickNo) {
    if ($workerId !== 0) {
        return;   // exactly one process owns the game loop
    }
    $last = microtime(true);
    Timer::tick(TICK_MS, function () use ($server, $players, $tickNo, &$last) {
        $now = microtime(true);
        $dt = $now - $last;   // use the real elapsed time: a timer can fire late
        $last = $now;
        $snapshot = [];
        foreach ($players as $fd => $p) {
            $x = $p['x'] + $p['dx'] * SPEED * $dt;
            $y = $p['y'] + $p['dy'] * SPEED * $dt;
            $players->set((string) $fd, ['x' => $x, 'y' => $y]);
            $snapshot[] = ['id' => $fd, 'x' => round($x, 2), 'y' => round($y, 2)];
        }
        $frame = json_encode(['type' => 'snapshot', 'tick' => $tickNo->add(), 'players' => $snapshot]);
        foreach ($server->connections as $fd) {
            if ($server->isEstablished($fd)) {
                $server->push($fd, $frame);
            }
        }
    });
});

$server->on('open', fn (Server $s, $req) => $players->set((string) $req->fd, ['x' => 0.0, 'y' => 0.0, 'dx' => 0, 'dy' => 0]));
$server->on('message', function (Server $s, Frame $f) use ($players) {
    $in = json_decode($f->data, true);
    if (is_array($in) && ($in['type'] ?? '') === 'input') {
        $players->set((string) $f->fd, [
            'dx' => max(-1, min(1, (int) ($in['dx'] ?? 0))),
            'dy' => max(-1, min(1, (int) ($in['dy'] ?? 0))),
        ]);
    }
});
$server->on('close', fn (Server $s, int $fd) => $players->del((string) $fd));
$server->start();

Three details carry the design. The loop runs only in worker 0, so there is exactly one simulation. It multiplies speed by the measured elapsed time ($dt) rather than by TICK_MS, because the timer can fire late under load. And input handlers only record the last direction; they never move the player, so the rate of messages cannot change the speed of the game. Run against two clients for two seconds it produced 40 snapshots (20 per second, as configured), and the moving player reached x = 8 (4 units per second multiplied by 2 seconds). Compare with the client-side techniques of Latency Compensation.

Scaling across servers with Redis

Shared memory ends at the machine. For two Swoole servers behind a load balancer, fan-out has to cross the network, and the usual backplane is Redis pub/sub (see Scaling WebSockets for the comparison with Streams and Kafka). The same server.php does this when REDIS_HOST is set:

  • broadcast() no longer pushes locally; it publishes (room, frame, skipUser) on the channel chatterbox:fanout with a connection taken from a Swoole\Database\RedisPool.

  • A user process added with $server->addProcess() subscribes to the channel and, for every message, runs the local fanout() over the instance’s own membership table. Because publishing and local delivery both go through Redis, a single server and a cluster behave the same, and ordering per publisher is preserved.

  • nextSeq() takes the per-room sequence number from Redis INCR, so two servers never assign the same seq.

The subscriber must be a separate process. The blocking subscribe loop is not covered by the runtime hooks, so in a worker it froze every connection of that worker; a user process created with enable_coroutine off has nothing else to do. The loop reconnects forever and sets the read timeout to -1, because by default phpredis gives up on an idle subscription after 60 seconds with a "read error on connection".

name: chatterbox-swoole-cluster
services:
  redis:
    image: redis:8.8.3-alpine
  chatterbox-a: &chatterbox
    image: phpswoole/swoole:6.2.3-php8.4
    working_dir: /app
    command: php server.php
    volumes: ["./app:/app:ro"]
    environment:
      REDIS_HOST: redis
      ORIGINS: "https://chat.example.test"
    depends_on: [redis]
    ports: ["8221:9501"]
  chatterbox-b:
    <<: *chatterbox
    ports: ["8222:9501"]

With one client on each server in the same room, a message from the client on A reached the client on B with seq 1 and a reply from B reached A with seq 2. What stays local in this version is the history, the idempotency rows and the member list: join on server B does not list users connected to A, and a duplicate sent to the other server after a reconnect is not detected. A production cluster keeps those in Redis (Streams for history, a key with SET NX and an expiry for the claim) or pins a user to one server.

Compression

'websocket_compression' => true lets Swoole negotiate permessage-deflate (RFC 7692) in the handshake, and the compress flag selects the frames to compress:

$server->set(['websocket_compression' => true]);
$server->push($fd, $json, WEBSOCKET_OPCODE_TEXT, SWOOLE_WEBSOCKET_FLAG_FIN | SWOOLE_WEBSOCKET_FLAG_COMPRESS);

The example leaves it off, for two reasons from The WebSocket Protocol: compressing data that mixes a secret with attacker-influenced text can leak the secret through the compressed length, and each compressed connection keeps zlib state in memory. Note that a server with a custom handshake, like the one above, writes the response headers itself and so would also have to negotiate the extension itself; the default handshake does it for you.

OpenSwoole

OpenSwoole is a fork of Swoole maintained by a separate group, with its own releases (26.2.0 on 2026-02-28) and its own documentation. The pages of this site that mention "Open Swoole" (the Octane driver name) refer to it. Differences that affect this page, verified by building OpenSwoole 26.2.0 on PHP 8.4:

  • Namespace. The native classes are OpenSwoole\WebSocket\Server, OpenSwoole\Table, OpenSwoole\Timer and so on. The Swoole\... names and the SWOOLE_* constants also exist in the build, as compatibility aliases, so most code written for Swoole loads unchanged; code that wants to be portable between the two should still decide on one set of names.

  • Constants. The native run modes are OpenSwoole\Server::SIMPLE_MODE (1, the equivalent of SWOOLE_BASE) and POOL_MODE (2, the equivalent of SWOOLE_PROCESS). Opcodes are class constants, for example OpenSwoole\WebSocket\Server::WEBSOCKET_OPCODE_PING.

  • Event names. Handlers are registered with capitalised names in the documentation (Open, Message, Close, WorkerStart).

  • API drift. OpenSwoole\Table has exists(); Swoole 6.2.3 has exist() and exists(). The 6.x additions this page mentions (thread mode, the Swoole\Table conditional operations of 6.3, the Swoole\Database pools) are Swoole-only: in the OpenSwoole 26.2.0 build Swoole\Thread and Swoole\Database\RedisPool do not exist, and connection pooling comes from the separate openswoole/core Composer package.

  • PHP version. openswoole/core 26.2.1 requires PHP 8.3 or newer and ext-openswoole 26.2.0 or newer.

  • Docker images. The official openswoole/swoole repository on Docker Hub had no 26.x tag at the time of writing (the newest was 25.2, and latest was version 22.1.2 on PHP 8.1), so a 26.2.0 server image is built from php:8.4-cli with pecl install openswoole-26.2.0.

A direct port of the room-counter idea, run on that build, answered two frames with seq 1 and 2 and logged a timer tick with the ping opcode 9:

<?php
declare(strict_types=1);

use OpenSwoole\Table;
use OpenSwoole\Timer;
use OpenSwoole\WebSocket\Frame;
use OpenSwoole\WebSocket\Server;

$rooms = new Table(1024);
$rooms->column('seq', Table::TYPE_INT, 8);
$rooms->create();

$server = new Server('0.0.0.0', 9503, Server::SIMPLE_MODE);
$server->set(['worker_num' => 1]);
$server->on('Message', function (Server $server, Frame $frame) use ($rooms) {
    $seq = $rooms->incr('general', 'seq');
    $server->push($frame->fd, json_encode(['seq' => $seq, 'echo' => $frame->data]));
});
$server->on('WorkerStart', function () use ($server) {
    Timer::tick(1000, fn () => error_log('tick, ping opcode ' . Server::WEBSOCKET_OPCODE_PING));
});
$server->start();

Choose by ecosystem. Hyperf and Laravel Octane document Swoole; OpenSwoole has its own Composer core package and its own documentation. They are different codebases with a different release cadence, so pin one and test upgrades of either.

Hyperf

Hyperf is a coroutine-based PHP framework built on Swoole. Its hyperf/websocket-server package, available in the 3.2 line (version 3.2.1 on 2026-10-24), wraps the callbacks of this page:

  • a server entry of type Server::SERVER_WEBSOCKET in config/autoload/server.php wires onHandShake, onMessage and onClose to Hyperf\WebSocketServer\Server;

  • routes are registered per server with Router::addServer('ws', ...), and a controller implements OnOpenInterface, OnMessageInterface and OnCloseInterface;

  • middleware is configured per server name, which is where an Origin check and the ticket validation of this page belong;

  • because onOpen, onMessage and onClose do not run in the same coroutine, a connected context keyed by fd stands in for the per-coroutine context.

The Chatterbox code above would become a controller class plus Hyperf’s own table or Redis components; the protocol, the Origin check, the ticket and the single-owner timers stay the same. Hyperf’s HTTP server also accepts WebSocket upgrades unless open_websocket_protocol is turned off for it.

Laravel Octane

Octane keeps a Laravel application in memory inside Swoole workers for HTTP, and exposes Swoole tables and ticks to the application. It is not a WebSocket server: real-time delivery in a Laravel app goes through broadcasting (Broadcasting and Reverb), and the Octane-specific caveats (state leaks between requests, max_request, the Swoole-only features) are in Octane and Performance. Everything above about per-worker memory applies to Octane unchanged.

Security

  • Origin. Check the header in the handshake, as above; allow-list exact origins, not suffixes. See Security.

  • Authentication. The browser cannot set headers, so the example uses a one-time ticket in the first message, consumed atomically and valid for 30 seconds. A ticket in a query string would end up in access logs. See Authentication and Authorization.

  • Limits. package_max_length bounds memory per connection before PHP runs; the application limit (16384 bytes), the token bucket (20 envelopes per second, burst 40) and max_connection bound the rest. Count ulimit -n too.

  • TLS. Terminate it in the proxy (nginx, see Proxies, Load Balancers and Deployment) or with ssl_cert_file and ssl_key_file; use wss:// from every browser page served over HTTPS.

  • The ticket endpoint. The demo accepts any user name. Never ship it: it is an authentication bypass.

  • Tables as a trust boundary. Everything stored in a table came from a client once; validate on the way in, as message does, because the values are broadcast to other users.

Common errors and pitfalls

  • State is per worker. A static property or global is not shared; use Swoole\Table, Atomic or Redis. A value set in one request is invisible to the next one only if it landed on another worker, which makes the bug intermittent.

  • The default run mode. new Server(...) runs SWOOLE_BASE since Swoole 5. Fan-out loops over connections, isEstablished and push to another worker’s sockets then silently do nothing. Pass SWOOLE_PROCESS or use a backplane.

  • Blocking calls. A call not covered by the hooks freezes every connection of the worker. Use coroutine-aware clients, pools and, for a blocking loop, a user process.

  • exist() is not isEstablished(). The first says "TCP connection"; only the second says "completed the WebSocket handshake".

  • Fixed-size tables. A full table makes set return false; an oversized string is truncated. Check the result and the length.

  • Keys over 63 bytes are cut with a warning; hash long names.

  • File descriptor reuse. A closed connection’s fd is handed to the next connection. A timer or queued job that keeps only the fd can act on the wrong client; carry a generation token and compare it, as the hello timeout does.

  • Uncaught exceptions kill the worker and every connection on it. Wrap the dispatch and log.

  • Subscriber idle timeout. An idle phpredis subscription ends with "read error on connection" after the default read timeout; disable the timeout and reconnect in a loop.

  • Closing with unread data. When the server closes while the client still sends, the TCP stack can reset the connection and the client sees 1006 instead of the close code. A test that floods the socket after the limit may need pacing to see 4429.

  • max_request. Recycling workers drops their connections; leave it off for WebSocket servers and fix the leak.

  • Deploys. reload restarts the workers: whatever they hold in their own memory is lost, and in SWOOLE_BASE so are their connections. Drain with a 4503 close and let clients reconnect with jitter, as in Connection Lifecycle, Heartbeats and Reconnection.

Version and specification notes

  • Swoole 5 changed the default server mode to SWOOLE_BASE. Swoole 6.0 added thread mode (Swoole\Thread, ZTS PHP only). In 6.2.3 the classes Swoole\Coroutine\Redis, Swoole\Coroutine\MySQL and Swoole\Coroutine\PostgreSQL no longer exist; the hooked phpredis and PDO drivers replace them. 6.2.3 (2026-09-22) is the current stable release; 6.3.0 is a release candidate that adds Swoole\Table::add, cmpset, cmpdel and getdel.

  • WebSocket: RFC 6455, with permessage-deflate compression from RFC 7692.

  • OpenSwoole 26.2.0 requires a newer PHP than Swoole 6.2 does; check the composer.json of any package you mix.

  • Hyperf 3.2 is the current line; its documentation site still uses the 3.1 path for the pages cited here.