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.
Table of Contents
- 1. Why classic PHP is unsuited for WebSockets
- 2. Ratchet's event loop model
- 3. MessageComponentInterface in detail
- 4. Building a minimal server
- 5. Practical example: real-time notifications
- 6. Broadcasting and targeted delivery
- 7. Integrating with an existing PHP application
- 8. Scaling limits of PHP WebSocket servers in production
- 9. Deployment and operations in practice
- 10. Summary
- 11. FAQ
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.