ez-php / websocket
PHP 8.5 Fiber-based WebSocket server for ez-php — RFC 6455 handshake, frame codec, ChannelManager pub/sub, and a non-blocking event loop.
Requires
- php: ^8.5
Requires (Dev)
- ez-php/docker: ^2.7.0
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
- ext-zlib: Needed for permessage-deflate compression (PerMessageDeflateConfig)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.7.1
- 2.7.0
- 2.6.5
- 2.6.4
- 2.6.3
- 2.6.2
- 2.6.1
- 2.6.0
- 2.5.10
- 2.5.9
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.11
- 2.4.10
- 2.4.9
- 2.4.8
- 2.4.7
- 2.4.6
- 2.4.5
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.9
- 2.3.8
- 2.3.7
- 2.3.6
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.1
- 2.2.0
- 2.1.1
- 2.1.0
- 2.0.1
- 2.0.0
- 1.14.0
- 1.13.1
- 1.13.0
- 1.12.2
- 1.12.1
- 1.12.0
- 1.11.2
- 1.11.1
- 1.11.0
- 1.10.0
- 1.9.2
- 1.9.1
- 1.9.0
- 1.8.0
- 1.7.1
- 1.7.0
- 1.6.1
- 1.6.0
- 1.5.1
- 1.5.0
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.0
- 1.2.0
This package is auto-updated.
Last update: 2026-10-30 15:48:19 UTC
README
PHP 8.5 Fiber-based WebSocket server for the ez-php ecosystem.
Implements RFC 6455 from the ground up — no third-party WebSocket library needed. Each connection runs in its own Fiber, allowing hundreds of concurrent clients on a single PHP process without threads or async extensions.
Intentionally separate from ez-php/broadcast (SSE/event-bus); this module
provides bidirectional, low-latency real-time communication.
Installation
composer require ez-php/websocket
No framework integration is needed — the server runs as a standalone long-lived process.
Quick start
<?php use EzPhp\WebSocket\ChannelManager; use EzPhp\WebSocket\ConnectionInterface; use EzPhp\WebSocket\Frame; use EzPhp\WebSocket\HandlerInterface; use EzPhp\WebSocket\Server; class ChatHandler implements HandlerInterface { public function __construct(private readonly ChannelManager $channels) {} public function onOpen(ConnectionInterface $conn): void { $this->channels->subscribe('general', $conn); $this->channels->broadcast('general', "{$conn->id()} joined."); } public function onMessage(ConnectionInterface $conn, Frame $frame): void { $this->channels->broadcast('general', "[{$conn->id()}] {$frame->payload}"); } public function onClose(ConnectionInterface $conn): void { $this->channels->unsubscribeAll($conn); $this->channels->broadcast('general', "{$conn->id()} left."); } public function onError(ConnectionInterface $conn, \Throwable $e): void { error_log("WebSocket error [{$conn->id()}]: {$e->getMessage()}"); } } $server = new Server('0.0.0.0', 8080); $server->run(new ChatHandler(new ChannelManager()));
Start the server:
php chat-server.php
Connect from a browser:
const ws = new WebSocket('ws://localhost:8080'); ws.onmessage = e => console.log(e.data); ws.onopen = () => ws.send('Hello!');
Core classes
Server
$server = new Server(host: '0.0.0.0', port: 8080); // optional: maxMessageBytes (default 16 MiB), subprotocols (e.g. ['graphql-transport-ws']), // compression: new PerMessageDeflateConfig() — accept permessage-deflate (needs ext-zlib) $server->run($handler); // blocks; handles SIGTERM externally
- Opens a TCP server socket via
stream_socket_server() - Spawns one
Fiberper accepted connection - Non-blocking
stream_select()event loop resumes Fibers when sockets are readable - Handles PING→PONG and CLOSE frames automatically
$server->hooks()lets other code join the loop:addReadStream($stream, fn ($s) => …)watches an extra socket (e.g. Redis Pub/Sub),removeReadStream($stream)stops watching it (call it before closing the stream, e.g. when the Redis connection drops),onTick(fn () => …)runs on every iteration (≈ every 50 ms) for timers
ConnectionInterface / Connection
$conn->id(); // unique string ID assigned by Server $conn->isConnected(); // false after close() or peer disconnect $conn->send('text'); // UTF-8 TEXT frame $conn->sendBinary($b); // BINARY frame $conn->close('bye'); // clean close with status 1000 $conn->closeWith(4400, 'Invalid message'); // close with an explicit status code $conn->subprotocol(); // negotiated Sec-WebSocket-Protocol, or null
Frame
Represents one RFC 6455 frame. Available in HandlerInterface::onMessage():
$frame->opcode; // Opcode::TEXT | Opcode::BINARY $frame->payload; // decoded (unmasked) payload string $frame->fin; // always true here — fragmented messages arrive reassembled (see below)
ChannelManager
Named pub/sub channels over connected clients:
$mgr->subscribe('room', $conn); $mgr->unsubscribe('room', $conn); $mgr->unsubscribeAll($conn); // called in onClose() $mgr->broadcast('room', 'message'); // sends TEXT frame to all connected subscribers $mgr->connections('room'); // list<ConnectionInterface> $mgr->channels(); // list<string> $mgr->count('room'); // int
broadcast() silently prunes disconnected connections.
Architecture notes
- Fragmented messages are reassembled: a
TEXT/BINARYframe with FIN=0 and itsCONTINUATIONframes reachonMessage()once, as one complete frame.PINGs between fragments are answered. A stray continuation, a new data frame inside an open message, or a fragmented control frame closes with1002. A message larger thanmaxMessageBytes(third constructor argument, default 16 MiB), whether one frame or the sum of its fragments, closes with1009. - permessage-deflate (RFC 7692) is opt-in: with
compression: new PerMessageDeflateConfig()the server accepts a client's offer, inflates messages whose first frame has RSV1 beforeonMessage()sees them, and compresses whatsend()/sendBinary()write.maxMessageBytesapplies to the inflated size (checked while inflating, so a small "zip bomb" is stopped early, with1009); undecodable data closes with1007, RSV1 without the extension or on control/continuation frames with1002.PerMessageDeflateConfig(serverNoContextTakeover: true, clientNoContextTakeover: true)trades ratio for ~256 KiB less memory per connection and direction. Control frames are never compressed. - Unmasked client frames are refused: per RFC 6455 §5.1 the server closes with status
1002. - No TLS (WSS): terminate TLS at a reverse proxy (nginx, Caddy) and use plain
ws://internally. - No authentication: verify cookies or tokens in
onOpen()and call$conn->close()on failure.
License
MIT