tests.ws

PHP WebSocket with Ratchet

php websocket ratchet server real-time

Ratchet is a PHP library for building real-time, bidirectional WebSocket servers. This Ratchet PHP library is built on top of ReactPHP’s event loop, which means your PHP WebSocket server runs persistently instead of following the traditional request-response lifecycle. If your stack is PHP and you need a WebSocket server without switching languages, Ratchet is the most established option.

Installation

Ratchet is installed through Composer. You need PHP 8.1 or newer.

composer require cboden/ratchet

This pulls in Ratchet and its dependencies, including react/event-loop, react/socket, and guzzlehttp/psr7. Your composer.json will look something like this:

{
    "require": {
        "cboden/ratchet": "^0.4"
    }
}

Verify the installation by creating a minimal script that imports Ratchet without errors:

<?php

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

echo 'Ratchet version loaded successfully' . PHP_EOL;

Basic Echo Server

Ratchet servers implement the MessageComponentInterface. This interface requires four methods: onOpen, onMessage, onClose, and onError. Each method receives a ConnectionInterface object representing a single client.

Create a file called EchoServer.php:

<?php

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class EchoServer implements MessageComponentInterface
{
    public function onOpen(ConnectionInterface $conn): void
    {
        echo "New connection: {$conn->resourceId}\n";
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        echo "Message from {$from->resourceId}: {$msg}\n";
        $from->send("Echo: {$msg}");
    }

    public function onClose(ConnectionInterface $conn): void
    {
        echo "Connection closed: {$conn->resourceId}\n";
    }

    public function onError(ConnectionInterface $conn, \Exception $e): void
    {
        echo "Error on {$conn->resourceId}: {$e->getMessage()}\n";
        $conn->close();
    }
}

Now create server.php to bootstrap and run the server:

<?php

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

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

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

echo "WebSocket server running on port 8080\n";
$server->run();

Run the server from your terminal:

php server.php

The server wraps your EchoServer in three layers. IoServer handles raw TCP connections. HttpServer parses the HTTP upgrade request that starts the WebSocket handshake. WsServer manages the WebSocket protocol framing. Your application code only deals with messages and connections.

Client Connection from JavaScript

Once the server is running, you can connect from any browser using the native WebSocket API. No client library is needed.

const ws = new WebSocket('ws://localhost:8080');

ws.addEventListener('open', () => {
    console.log('Connected to Ratchet server');
    ws.send('Hello from JavaScript');
});

ws.addEventListener('message', (event) => {
    console.log('Server replied:', event.data);
});

ws.addEventListener('close', () => {
    console.log('Disconnected');
});

ws.addEventListener('error', (err) => {
    console.error('WebSocket error:', err);
});

If you want to test without building a frontend, you can use the WebSocket tester on tests.ws or any command-line WebSocket client like wscat.

Broadcasting to All Connected Clients

Most real applications need to send messages to every connected client, not just the sender. To do this, you track all connections in an SplObjectStorage instance.

<?php

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class BroadcastServer implements MessageComponentInterface
{
    protected \SplObjectStorage $clients;

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

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->clients->attach($conn);
        echo "Client {$conn->resourceId} connected. Total: {$this->clients->count()}\n";
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        foreach ($this->clients as $client) {
            if ($client !== $from) {
                $client->send($msg);
            }
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        $this->clients->detach($conn);
        echo "Client {$conn->resourceId} disconnected. Total: {$this->clients->count()}\n";
    }

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

The onMessage method loops through all connected clients and sends the message to everyone except the sender. SplObjectStorage is a good fit here because it allows you to attach and detach connection objects efficiently without managing array keys.

Rooms and Channels

A common pattern is grouping connections into rooms or channels. Clients subscribe to a room and only receive messages broadcast to that room. You can implement this with an associative array keyed by room name.

<?php

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class RoomServer implements MessageComponentInterface
{
    /** @var array<string, \SplObjectStorage> */
    protected array $rooms = [];

    protected \SplObjectStorage $clients;

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

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->clients->attach($conn);
        $conn->rooms = [];
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        $data = json_decode($msg, true);
        if ($data === null) {
            $from->send(json_encode(['error' => 'Invalid JSON']));
            return;
        }

        match ($data['action'] ?? '') {
            'join' => $this->joinRoom($from, $data['room'] ?? ''),
            'leave' => $this->leaveRoom($from, $data['room'] ?? ''),
            'message' => $this->broadcastToRoom($from, $data['room'] ?? '', $data['body'] ?? ''),
            default => $from->send(json_encode(['error' => 'Unknown action'])),
        };
    }

    protected function joinRoom(ConnectionInterface $conn, string $room): void
    {
        if ($room === '') {
            return;
        }

        if (!isset($this->rooms[$room])) {
            $this->rooms[$room] = new \SplObjectStorage();
        }

        $this->rooms[$room]->attach($conn);
        $conn->rooms[] = $room;

        $conn->send(json_encode([
            'action' => 'joined',
            'room' => $room,
            'members' => $this->rooms[$room]->count(),
        ]));
    }

    protected function leaveRoom(ConnectionInterface $conn, string $room): void
    {
        if (isset($this->rooms[$room])) {
            $this->rooms[$room]->detach($conn);
            if ($this->rooms[$room]->count() === 0) {
                unset($this->rooms[$room]);
            }
        }
    }

    protected function broadcastToRoom(ConnectionInterface $from, string $room, string $body): void
    {
        if (!isset($this->rooms[$room])) {
            return;
        }

        $payload = json_encode([
            'action' => 'message',
            'room' => $room,
            'body' => $body,
            'sender' => $from->resourceId,
        ]);

        foreach ($this->rooms[$room] as $client) {
            if ($client !== $from) {
                $client->send($payload);
            }
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        foreach ($conn->rooms as $room) {
            $this->leaveRoom($conn, $room);
        }
        $this->clients->detach($conn);
    }

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

Clients join and leave rooms by sending JSON messages with an action field. When a message is sent to a room, only members of that room receive it. Empty rooms are cleaned up automatically to avoid memory leaks.

JSON Messaging

Structured JSON messaging is the standard approach for WebSocket applications. Define a consistent message format with a type or action field so both server and client can route messages properly.

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

    if (!is_array($data) || !isset($data['type'])) {
        $from->send(json_encode([
            'type' => 'error',
            'message' => 'Messages must be JSON with a "type" field',
        ]));
        return;
    }

    match ($data['type']) {
        'ping' => $from->send(json_encode(['type' => 'pong'])),
        'chat' => $this->handleChat($from, $data),
        'status' => $this->handleStatus($from, $data),
        default => $from->send(json_encode([
            'type' => 'error',
            'message' => "Unknown message type: {$data['type']}",
        ])),
    };
}

Always validate incoming JSON before processing. A malformed payload should result in an error message back to the client, not a crash. The match expression in PHP 8.0+ keeps the routing clean and readable.

Authentication

WebSocket connections start with an HTTP upgrade request, which means you can inspect query parameters or headers during onOpen. A common pattern is passing a token as a query string parameter and validating it when the connection opens.

<?php

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class AuthenticatedServer implements MessageComponentInterface
{
    protected \SplObjectStorage $clients;

    public function __construct(
        protected readonly TokenValidator $tokenValidator
    ) {
        $this->clients = new \SplObjectStorage();
    }

    public function onOpen(ConnectionInterface $conn): void
    {
        $queryString = $conn->httpRequest->getUri()->getQuery();
        parse_str($queryString, $params);

        $token = $params['token'] ?? '';

        if ($token === '' || !$this->tokenValidator->isValid($token)) {
            $conn->send(json_encode(['error' => 'Unauthorized']));
            $conn->close();
            return;
        }

        $conn->userId = $this->tokenValidator->getUserId($token);
        $this->clients->attach($conn);

        echo "Authenticated user {$conn->userId} connected\n";
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        // $from->userId is available here
        foreach ($this->clients as $client) {
            $client->send(json_encode([
                'userId' => $from->userId,
                'message' => $msg,
            ]));
        }
    }

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

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

The client connects with the token in the URL:

const ws = new WebSocket('ws://localhost:8080?token=your-jwt-token-here');

You can also read cookies or specific headers from $conn->httpRequest, which is a PSR-7 RequestInterface. For more background on what happens during connection setup, see the WebSocket handshake documentation.

Error Handling

The onError method catches exceptions thrown during message processing. Always close the connection in onError to prevent the client from hanging in a broken state.

public function onError(ConnectionInterface $conn, \Exception $e): void
{
    error_log(sprintf(
        'WebSocket error on connection %d: %s in %s:%d',
        $conn->resourceId,
        $e->getMessage(),
        $e->getFile(),
        $e->getLine()
    ));

    $conn->send(json_encode([
        'type' => 'error',
        'message' => 'Internal server error',
    ]));

    $conn->close();
}

You should also wrap logic in your onMessage handler with try-catch blocks for expected exceptions, so one bad message does not bring down the connection.

public function onMessage(ConnectionInterface $from, $msg): void
{
    try {
        $data = json_decode($msg, true, 512, JSON_THROW_ON_ERROR);
        $this->processMessage($from, $data);
    } catch (\JsonException $e) {
        $from->send(json_encode(['type' => 'error', 'message' => 'Invalid JSON']));
    } catch (\InvalidArgumentException $e) {
        $from->send(json_encode(['type' => 'error', 'message' => $e->getMessage()]));
    } catch (\Throwable $e) {
        error_log("Unexpected error: {$e->getMessage()}");
        $from->close();
    }
}

Never expose internal error details to clients. Log the full exception server-side and send a generic error message over the socket.

Running as a Daemon

A Ratchet server is a long-running PHP process. In production, you need a process manager to restart it if it crashes and to start it on boot.

Systemd

Create a systemd unit file at /etc/systemd/system/websocket.service:

[Unit]
Description=Ratchet WebSocket Server
After=network.target

[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/websocket-app
ExecStart=/usr/bin/php server.php
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Enable and start the service:

sudo systemctl enable websocket
sudo systemctl start websocket
sudo systemctl status websocket

Supervisor

Alternatively, use Supervisor. Create a config file at /etc/supervisor/conf.d/websocket.conf:

[program:websocket]
command=php /var/www/websocket-app/server.php
autostart=true
autorestart=true
user=www-data
stdout_logfile=/var/log/websocket.log
stderr_logfile=/var/log/websocket-error.log

Reload Supervisor and start the process:

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start websocket

Both approaches achieve the same result. Systemd is more common on modern Linux distributions. Supervisor is a good choice if you already use it for other processes or need more granular control over log rotation.

Production Tips

Nginx Reverse Proxy

In production, you should place Ratchet behind Nginx. This lets you terminate TLS at the Nginx layer, serve your WebSocket server on standard ports (80/443), and share the domain with your regular web application.

server {
    listen 443 ssl;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location /ws {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

The proxy_read_timeout and proxy_send_timeout values are set high to prevent Nginx from closing idle WebSocket connections. Adjust these based on your application’s needs. With this configuration, clients connect to wss://example.com/ws and Nginx forwards the connection to your Ratchet process on port 8080. For more on how encrypted WebSocket connections work, see WebSocket Security.

TLS

Never run WebSocket connections over plain ws:// in production. Browsers increasingly restrict mixed content, and many corporate proxies will strip or block unencrypted WebSocket connections. Always use wss:// by terminating TLS at your reverse proxy or load balancer.

Memory and Connection Limits

Each connected client consumes memory in your PHP process. Monitor memory usage with memory_get_usage() and set a hard limit on concurrent connections if needed.

public function onOpen(ConnectionInterface $conn): void
{
    if ($this->clients->count() >= 10000) {
        $conn->send(json_encode(['error' => 'Server at capacity']));
        $conn->close();
        return;
    }

    $this->clients->attach($conn);
}

Also increase the PHP memory limit in your server script or php.ini:

ini_set('memory_limit', '512M');

A single Ratchet process can handle thousands of concurrent connections, but you should load test your specific application to find the practical limit. If you need to scale beyond one process, run multiple instances on different ports behind a load balancer with sticky sessions.

Alternatives

Ratchet is not the only option for WebSocket servers in PHP. Here are other libraries worth considering.

Swoole WebSocket Server

Swoole is a PHP extension written in C that provides an asynchronous event-driven programming framework. Its built-in WebSocket server is significantly faster than Ratchet because it runs at the extension level. The trade-off is that Swoole requires installing a PECL extension, which complicates deployment on shared hosting.

ReactPHP

ReactPHP is the event loop library that Ratchet is built on. You can build a WebSocket server directly with ReactPHP and the react/http package, giving you more control over the HTTP layer. This is a good choice if you want lower-level access or are already using ReactPHP for other async work.

Workerman

Workerman is a high-performance async event-driven framework for PHP. It supports WebSocket out of the box and can handle more concurrent connections than Ratchet in benchmarks. Workerman also supports running multiple worker processes, which Ratchet does not natively support.

If you are evaluating options and want to test how your server handles different message types, the tests.ws online tester can help you verify behavior across libraries.

FAQ

Can Ratchet run alongside a Laravel or Symfony application?

Yes. You can share your application’s models, services, and configuration with your Ratchet server. Create the Ratchet server script within your framework project, require the framework’s autoloader, and bootstrap the dependency container. The Ratchet process runs separately from your web server, so it will not interfere with HTTP request handling. Laravel has a package called beyondcode/laravel-websockets that wraps Ratchet with Laravel-specific tooling.

How do I send messages from my web application to connected WebSocket clients?

Ratchet runs in its own process, so your web application cannot directly call methods on the WebSocket server. The common approach is using a message queue or an internal ZeroMQ (ZMQ) push-pull socket. Ratchet includes a ZmqContext component that listens for messages from other PHP processes. Your web controller publishes a message to ZMQ, and the Ratchet server receives it and broadcasts to the appropriate clients.

Does Ratchet support WSS (WebSocket Secure)?

Ratchet itself does not handle TLS termination. The recommended approach is to run Ratchet behind a reverse proxy like Nginx or HAProxy that terminates TLS. This is the same pattern used by most Node.js and Python WebSocket servers in production. The reverse proxy handles certificates and encryption, and forwards plain WebSocket traffic to Ratchet over localhost.

What happens when the Ratchet process crashes?

If the PHP process exits unexpectedly, all connected clients are disconnected immediately. This is why running Ratchet under a process manager like systemd or Supervisor is essential. The process manager restarts the server automatically, and clients can reconnect. Build reconnection logic into your JavaScript WebSocket client so that users experience only a brief interruption rather than a permanent disconnect.