Building a WebSocket Server in PHP with Ratchet
AI generated
8.4
PHP · Ratchet · WebSockets
Building a WebSocket Server in PHP with Ratchet
From the request-response model to a long-lived process

PHP was designed for a model where every request spins up a new process or at least a fresh execution context, which does not fit WebSockets, which expect a permanently open connection. Ratchet solves that problem via an event loop built on ReactPHP. We build a real-time notification server and show concretely where the scaling limits of a PHP WebSocket server in production actually sit.

16 min read Ratchet MessageComponentInterface Event Loop Real Time

1. Why classic PHP is unsuited for WebSockets

A classic PHP setup under PHP-FPM or Apache with mod_php ends a script's execution context as soon as the response has been sent to the client. That is exactly right for HTTP requests, each request is independent, memory gets fully released afterward, and the next request starts from zero again. That very model, however, prevents a WebSocket connection that needs to stay open for minutes or hours so the server can push data to the client at any time on its own.

WebSockets need a process that runs continuously, keeps multiple simultaneous connections in memory, and actively reacts to events instead of terminating after a single response. PHP-FPM is architecturally not designed for that pattern, which is why a WebSocket server in PHP has to run as its own long-lived process outside the classic web server request cycle, typically started via the PHP CLI.

2. Ratchet's event loop model

Ratchet builds on ReactPHP and uses its event loop to manage thousands of simultaneous connections within a single PHP process. Instead of spawning a separate thread or process per connection, the event loop registers non-blocking socket operations and invokes registered callbacks as soon as data actually becomes available, similar to Node.js's model.

This architecture concretely means your own code never actively waits on a connection, it instead reacts to method calls that Ratchet triggers itself at the right moment. Blocking operations like a synchronous, long-running database query are particularly dangerous in this model, because they freeze the entire event loop and, with it, every other open connection for the duration of the block.

3. MessageComponentInterface in detail

The entry point for custom WebSocket logic in Ratchet is MessageComponentInterface with four methods: onOpen() gets called for a new connection, onMessage() for an incoming message, onClose() when a connection tears down, and onError() on a connection error. Every method receives a ConnectionInterface object representing the concrete connection and allowing you to send data via send().

These four methods cover the complete lifecycle of a WebSocket connection. Unlike an HTTP request, which goes through a single request-response cycle, a WebSocket connection can trigger any number of onMessage() calls in both directions over its entire lifetime, which is why per-connection state, such as an associated user, has to be kept in a dedicated data structure.


<?php

declare(strict_types=1);

namespace Ratchet;

interface MessageComponentInterface
{
    public function onOpen(ConnectionInterface $conn): void;

    public function onMessage(ConnectionInterface $from, $msg): void;

    public function onClose(ConnectionInterface $conn): void;

    public function onError(ConnectionInterface $conn, \Exception $e): void;
}

4. Building a minimal server

A minimal Ratchet server consists of a custom class implementing MessageComponentInterface, wrapped in a WsServer for the WebSocket protocol and an HttpServer for the initial HTTP upgrade that technically kicks off every WebSocket connection. These layers get combined via IoServer::factory() and bound to a concrete port.

The server process then runs continuously via run() until explicitly stopped. Unlike a classic PHP script, this process does not end after a single action, it continuously processes new connections and messages in the event loop's infinite loop, which is why Supervisor or a comparable process monitoring tool is essential for production operation.


<?php

declare(strict_types=1);

use Ratchet\Http\HttpServer;
use Ratchet\Server\IoServer;
use Ratchet\WebSocket\WsServer;

require __DIR__ . '/vendor/autoload.php';

$server = IoServer::factory(
    new HttpServer(
        new WsServer(new NotificationServer()),
    ),
    8080,
);

$server->run();

5. Practical example: real-time notifications

A notification component maintains an SplObjectStorage keyed by every open ConnectionInterface instance. In onOpen(), the new connection is added to that structure, in onClose() it gets removed again, so the server always knows exactly which clients are currently connected, without needing an external database for that state.

An incoming message in onMessage() is typically decoded as JSON to determine the message type and target audience, for example a notification for a single user or a broadcast to every connection. It is important to validate every incoming message defensively, since a WebSocket endpoint receives unfiltered input from the outside just like any other publicly reachable endpoint from a security perspective.


<?php

declare(strict_types=1);

namespace App\WebSocket;

use Ratchet\ConnectionInterface;
use Ratchet\MessageComponentInterface;

final class NotificationServer implements MessageComponentInterface
{
    private \SplObjectStorage $connections;

    public function __construct()
    {
        $this->connections = new \SplObjectStorage();
    }

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->connections->attach($conn);
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        $payload = json_decode((string) $msg, true);

        if (!is_array($payload) || !isset($payload['type'])) {
            $from->send(json_encode(['error' => 'invalid_payload']));
            return;
        }

        $this->broadcast($payload, $from);
    }

    public function onClose(ConnectionInterface $conn): void
    {
        $this->connections->detach($conn);
    }

    public function onError(ConnectionInterface $conn, \Exception $e): void
    {
        $conn->close();
    }

    private function broadcast(array $payload, ConnectionInterface $sender): void
    {
        foreach ($this->connections as $connection) {
            if ($connection !== $sender) {
                $connection->send(json_encode($payload));
            }
        }
    }
}

6. Broadcasting and targeted delivery

The example above shows a simple broadcast to every connection except the sender, but many applications need targeted delivery to individual users instead. For that, you extend the SplObjectStorage with additional metadata per connection, for example a user ID resolved from an authentication token during the connection handshake in the HTTP upgrade request.

Instead of iterating over every connection, you then filter specifically for the matching user ID before sending a message. At very high connection counts it also pays off to maintain a map from user ID to connection, speeding up delivery from a linear search to direct lookup, which makes a noticeable difference at thousands of connections.

7. Integrating with an existing PHP application

The actual trigger for a notification, such as a new order, usually originates in the classic PHP-FPM application, not in the WebSocket process itself. Since both processes run separately and share no memory, a bridge is needed, usually Redis Pub/Sub: the FPM application publishes an event on a Redis channel, the WebSocket server subscribes to that channel through the ReactPHP Redis integration, and forwards incoming messages to the matching connections.

This separation is not a downside, it is architecturally sound: the FPM application stays responsible for the classic request-response business, the WebSocket process handles exclusively open connections and real-time delivery, and both communicate only through the decoupled Redis channel.

8. Scaling limits of PHP WebSocket servers in production

A single Ratchet process runs single-threaded inside the ReactPHP event loop, genuine parallel processing across multiple CPU cores is not possible without additional tooling. For more capacity, you start several worker processes on different ports, which immediately raises the problem that connections get distributed randomly across workers, and a broadcast then no longer reaches every client without also using the Redis Pub/Sub bridge between the workers themselves.

For load distribution across multiple workers, a WebSocket setup also needs sticky sessions at the load balancer level, since an existing connection has to stay pinned to the same worker for its entire lifetime. At very high connection counts, for example tens of thousands of simultaneous clients, PHP based solutions increasingly hit practical limits, and alternatives like a dedicated Node.js process, a hosted service like Pusher, or a specialized solution like Mercure often become the lower maintenance choice.

9. Deployment and operations in practice

Because the WebSocket server runs as a long-lived CLI process, it needs explicit process monitoring unlike PHP-FPM. Supervisor is well suited for that, since it automatically restarts a crashed process and collects logs centrally, without you having to write a custom watchdog script.

For zero downtime deployments, a plain process restart is problematic because it hard disconnects every currently open connection. In practice, a rolling restart across several worker processes works well, restarting only a portion of the workers at a time while the load balancer routes new connections exclusively to the already updated workers in the meantime.

Approach Parallelism Operational effort Fit
Single Ratchet process Single-threaded, event loop Low Prototypes, small user counts
Multiple Ratchet workers plus Redis Pub/Sub Multiple processes coordinated via Redis Medium Mid sized applications with clear capacity planning
Dedicated Node.js WebSocket service Event driven, mature ecosystem Medium to high, additional stack Once PHP limits are reached
Hosted service like Pusher Managed by the provider Low, but ongoing cost When avoiding self hosting is a priority
Mercure SSE based, HTTP/2 native Low to medium Server to client push without full WebSocket complexity

Mironsoft

PHP modernization, code quality, and legacy refactoring

Grown PHP code nobody wants to touch anymore?

We modernize PHP codebases to current language standards, introduce static analysis and coding standards, and refactor legacy code step by step without endangering live operations.

Legacy Refactoring

Modernize grown PHP code in a structured, low-risk way.

Establishing Code Quality

Anchor PHPStan, coding standards, and CI checks sustainably in the team.

Version Upgrades

Plan and execute PHP major version upgrades safely, without downtime.

10. Summary

WebSocket Server with Ratchet: The Essentials at a Glance

A different model

WebSockets require a long-lived process instead of the classic PHP request-response cycle.

Event loop

Ratchet uses ReactPHP to manage thousands of connections non-blockingly within a single process.

Four methods

MessageComponentInterface covers the entire connection lifecycle via onOpen, onMessage, onClose and onError.

Know the limits

High connection counts require multiple workers, Redis bridging and sticky sessions, at which point Node.js sometimes pays off.

11. FAQ: WebSocket Server with Ratchet: The Essentials at a Glance

1Why doesn't a normal PHP-FPM process work for WebSockets?
Because PHP-FPM terminates a process and frees its memory after every response, while WebSockets need a permanently open connection through a long-lived process.
2What does ReactPHP's event loop actually do?
It watches non-blocking socket operations and invokes registered callbacks as soon as data actually becomes available, without spawning a separate thread per connection.
3Which four methods do I need to implement for MessageComponentInterface?
onOpen for new connections, onMessage for incoming messages, onClose for disconnects, and onError for errors on the connection.
4How do I send a message to every connected client?
Via an SplObjectStorage holding every open ConnectionInterface instance, and an iteration over it that calls send() on every connection except the sender.
5How does the WebSocket process learn about events from the classic PHP application?
Through a bridge, usually Redis Pub/Sub, where the FPM application publishes an event and the WebSocket server receives it over a subscribed channel.
6Why is a blocking database query particularly dangerous in Ratchet?
Because it freezes the entire event loop, stalling every other open connection of the process for the duration of the block, not just its own.
7How do I scale a Ratchet server across multiple CPU cores?
Through multiple worker processes on different ports, combined with sticky sessions at the load balancer and a Redis Pub/Sub bridge for broadcasts across connections.
8At what point should I switch from Ratchet to Node.js or Mercure?
At very high connection counts, for example tens of thousands of simultaneous clients, or when the operational overhead of multiple PHP workers outweighs the benefit.
9How do I restart a Ratchet server in production cleanly without dropping connections?
Via a rolling restart across multiple worker processes, restarting only a portion at a time while the load balancer routes new connections to the already updated workers.
10Why does a Ratchet process need Supervisor or a similar tool?
Because it runs as a long-lived CLI process and, unlike PHP-FPM, has no built in process monitoring, a crash would otherwise go unnoticed.