ratchet/rfc6455
A framework-agnostic RFC6455 WebSocket protocol handler for PHP, with components for both server- and client-side handshake and messaging negotiation. It performs no I/O itself; HTTP upgrade integration is handled through PSR-7 interfaces, leaving implementation-specific interpretations open.
What you are actually installing. ratchet/rfc6455 is a protocol codec, not a server. composer require ratchet/rfc6455 gives you frame parsing, message assembly, and handshake negotiation — and nothing else. There is no service provider, no facade, no config file, no artisan command. If you expect composer require to produce a WebSocket endpoint, stop here and evaluate Laravel Reverb instead. If you intend to own a long-running socket process, this is the protocol layer you build on.
The minimum viable dependency set for a Laravel app is three packages, not one:
composer require ratchet/rfc6455 react/socket nyholm/psr7
react/socket supplies the I/O this library deliberately omits; nyholm/psr7 supplies the PSR-7/PSR-17 objects the handshake consumes and produces. Any PSR-7 implementation works — pick one and standardise, because mixing two in one Composer graph is a recurring source of confusing type errors.
Where to look first. Read these five classes in this order; they are the entire day-to-day surface:
| Class | Role |
|---|---|
Ratchet\RFC6455\Handshake\ServerNegotiator |
Turns a PSR-7 request into a PSR-7 101 response (or a rejection) |
Ratchet\RFC6455\Handshake\ClientNegotiator |
Builds the upgrade request and validates the response — only needed if you are writing a client |
Ratchet\RFC6455\Messaging\MessageBuffer |
The state machine: feed it raw bytes, get complete messages and control frames back |
Ratchet\RFC6455\Messaging\Frame |
Individual frames; Frame::create() to encode, Frame::fromBuffer() to decode |
Ratchet\RFC6455\Handshake\PermessageDeflateOptions |
Compression negotiation, if you enable it |
First use case — a loopback echo inside an artisan command. Do this before you write any Laravel integration, because it isolates protocol bugs from auth/broadcast bugs:
// app/Console/Commands/WebsocketServe.php
$loop = React\EventLoop\Loop::get();
$factory = new React\Socket\SocketServer('0.0.0.0:8081', [], $loop);
$negotiator = new ServerNegotiator(new RequestVerifier(), ['chat.v1']);
$factory->on('connection', function (React\Socket\ConnectionInterface $conn) use ($negotiator) {
$buffer = null;
$conn->on('data', function (string $chunk) use (&$buffer, $negotiator, $conn) {
if ($buffer === null) {
// First chunk is HTTP. Convert to PSR-7, negotiate, reply.
$request = Nyholm\Psr7\Factory\Psr17Factory::fromGlobals(); // in real code: parse $chunk
$response = $negotiator->handshake($request);
$conn->write(Message::toString($response)); // status line + headers + \r\n\r\n
if ($response->getStatusCode() !== 101) {
$conn->end();
return;
}
$buffer = new MessageBuffer(
new CloseFrameChecker(),
fn (MessageInterface $msg) => $conn->write(
Frame::create($msg->getPayload(), true, Frame::OP_TEXT, false)
),
fn (FrameInterface $frame) => $conn->write($frame->getContents()),
null,
fn (\Exception $e) => $conn->close()
);
return;
}
$buffer->onData($chunk); // ← this is the whole messaging story
});
});
Note the shape: MessageBuffer::onData($chunk) is your handle() method. Everything else — masking, continuation frames, control-frame interleaving, UTF-8 validation, close handshakes — happens inside it. Once this echoes a browser message, you have proven the hard part and can start layering Laravel concerns.
The socket process has no session, no cookie jar, and no reason to be inside the FPM lifecycle. The standard pattern is a short-lived signed ticket issued by Laravel and validated locally by the socket process.
// Laravel side — routes/web.php or an API route
Route::post('/ws/ticket', function (Request $request) {
return ['ticket' => URL::temporarySignedRoute('ws.ticket', now()->addSeconds(30), [
'user' => $request->user()->id,
'room' => $request->input('room'),
])];
});
The client appends the ticket to the upgrade URL as a query string; the socket process validates the signature with the shared APP_KEY (or a dedicated HMAC secret) and extracts the user id without touching the database or hitting an HTTP endpoint. This keeps connection storms from becoming database storms, and keeps the socket process deployable independently.
Where you do need a round trip — e.g. verifying a room membership that can change mid-session — push the change over Redis pub/sub rather than polling.
Define the wire contract before the transport. A versioned envelope that survives a swap to Reverb or a managed service is worth more than any amount of transport code:
final class Envelope
{
public function __construct(
public readonly string $type, // 'message.send', 'presence.join', 'error'
public readonly string $v = 'v1',
public readonly array $payload = [],
) {}
public function toJson(): string { return json_encode(get_object_vars($this), JSON_THROW_ON_ERROR); }
public static function fromFrame(MessageInterface $msg): self
{
if ($msg->getOpcode() !== Frame::OP_TEXT) {
throw new ProtocolException('binary frames not accepted on this channel');
}
$data = json_decode($msg->getPayload(), true, 32, JSON_THROW_ON_ERROR);
return new self($data['type'], $data['v'] ?? 'v1', $data['payload'] ?? []);
}
}
Then the message handler is a match on $type dispatching to small handlers — the same thing you would write against any other transport. Enforce the version here; it is your only cheap migration lever once clients are in the wild.
Laravel's broadcast events should not know the socket process exists. Use a Laravel event listener that publishes to Redis, and have the socket process subscribe:
// App\Listeners\PublishToSocketTier
public function handle(OrderShipped $event): void
{
Redis::connection('ws')->publish('ws:broadcast', json_encode([
'room' => "order.{$event->order->id}",
'type' => 'order.shipped',
'payload' => ['tracking' => $event->order->tracking],
]));
}
The socket process holds a Room registry (SplObjectStorage from connection → rooms) and fans out. This is the piece that does not come with the package — no channels, no subscriptions, no presence, no authorization gates. Budget for it explicitly; it is the most common source of estimate slippage.
ServerNegotiator takes an array of supported subprotocols and returns 426 when the client's request doesn't intersect. This is a cheap, standards-compliant way to version your API:
$negotiator = new ServerNegotiator(new RequestVerifier(), ['chat.v2', 'chat.v1']);
Clients announce Sec-WebSocket-Protocol: chat.v2; the response echoes the chosen one. Rejecting unknown subprotocols early is far cheaper than discovering a schema mismatch on the third message.
Permessage-deflate is supported (since 0.3) but is not free: it costs CPU per message and it is a well-known source of both memory-amplification attacks and subtle bugs in older proxies. Negotiate it via PermessageDeflateOptions::fromRequest(), pass the resulting options into MessageBuffer, and do not enable it for small, high-frequency messages — the deflate overhead exceeds the payload. Where you do enable it, cap server_max_window_bits and require context takeover to be off.
The single biggest day-to-day ergonomic win of an I/O-free library is that you can test the protocol synchronously. No server, no ports, no flake:
it('acks a valid close frame with the same code', function () {
$buffer = new MessageBuffer(
new CloseFrameChecker(),
fn () => null,
fn (FrameInterface $f) => $this->closeFrames[] = $f,
);
$buffer->onData(Frame::create("bye", true, Frame::OP_CLOSE, true)->getContents());
expect($this->closeFrames)->toHaveCount(1)
->and($this->closeFrames[0]->getPayload())->toContain('bye');
});
Build a small FrameFactory test helper that emits masked client frames — masking is required from clients and forbidden from servers, so hand-rolled fixtures get this wrong constantly.
Masking direction is not symmetric. Clients MUST mask, servers MUST NOT. Frame::create($payload, $final, $opcode, $masked) — the fourth argument defaults to true, which is correct for a client and wrong for a server. Getting this backwards produces frames that look structurally valid and are silently rejected by every browser. Set false explicitly on every server-side encode and never rely on the default.
Set MessageBuffer::$maxBufferLength before you ship. The default is ~2 MB; it is a public static property, so the natural instinct is to leave it alone and get bitten later by a client trickling bytes and never completing a message. Set it explicitly at bootstrap, and map overflow to close code 1009 (Message Too Big):
MessageBuffer::$maxBufferLength = 1024 * 1024; // 1 MB, deliberate
Note this is global static state — awkward under tests running in parallel, and awkward if you ever want different limits per channel. If you need per-connection limits, enforce your own byte counter alongside it.
Control frames arrive on a different callback and can interleave with data frames. Your $onControl handler must handle PING, PONG, and CLOSE even when a fragmented message is mid-flight. Never assume the next callback after $onMessage is another $onMessage.
Close is a two-step handshake. The library surfaces the peer's close frame and will ack automatically when configured, but you still own the teardown: stop sending, drain, then close the TCP socket. Closing TCP immediately after receiving a close frame is legal but truncates in-flight data on some networks; browsers surface that as a spurious 1006.
UTF-8 validation happens in the message buffer, not your code. Invalid text frames produce an exception callback, not a $onMessage. Wire $onException to something that logs with connection id and closes with 1007. A silent fn() => null here means corrupt connections linger.
PSR-7/PSR-17 duplication in the Composer graph. nyholm/psr7, guzzlehttp/psr7, and laminas-diactoros can coexist but produce objects of different concrete classes. ServerNegotiator type-hints the interfaces, so it works — but your own handshake plumbing may not. Pin one factory and inject it consistently.
The handshake response goes raw to the socket, not through Laravel's response stack. You write HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n...\r\n\r\n yourself. Any middleware that touches headers downstream is irrelevant here. Conversely, rejection responses (400/426) from ServerNegotiator are ordinary PSR-7 responses you can serialise and write — a useful debugging aid since you cannot use dd() on a socket process.
Pre-1.0, so pin exact versions. 0.4.0 raised the PHP floor to 7.4 and swapped Guzzle request/response objects for PSR-17 factories — a breaking change to anyone who typed against the old classes. Use "ratchet/rfc6455": "0.4.1" (no caret), read the changelog before bumping, and add the repo to your advisory watchlist; there is no vendor security channel.
Verify the release provenance before pinning. The recorded latest release carries a future date (2026-06-06). Check the tag, the signature, and the diff against the previous tag rather than trusting the metadata. When release cadence is unproven, treat upgrades as deliberate review events, not composer update side effects.
Deployment and proxies will cost you more than the code. nginx needs proxy_http_version 1.1 and explicit Upgrade/Connection header passthrough; ALB needs the idle timeout raised far above the 60s default, or your connections die on a nice round interval. Heartbeats at ~30s (application-level ping) are cheaper than debugging "why do connections drop after exactly a minute."
Extension points are narrow by design — lean on composition. There is no plugin system. The extension surface is: the callbacks you pass to MessageBuffer, a custom CloseFrameChecker subclass if you need a different close-code policy, and the exception factory for flow-control and validation errors. If you find yourself wanting to subclass MessageBuffer, you probably want a decorator that sits between the socket and the buffer instead.
Octane is irrelevant here, in a good way. The socket process is separate from your FPM/Octane workers, so framework upgrades cannot break the transport. The corollary: the socket process gets no Octane reload, no Horizon supervision, and no artisan graceful restart. Supervisor config, SIGTERM handling, and connection draining are yours to write. Land them in the first milestone, not a phase two that never gets funded.
How can I help you explore Laravel packages today?