Web Socket #

WebSocket is a full-duplex communication protocol over a single persistent TCP connection — unlike HTTP, which is request-response, WebSocket lets the server and client send messages to each other at any time without waiting for one another. It’s the technology behind real-time chat, instant notifications, live dashboards, collaborative editing, and web-based multiplayer games. PHP isn’t a language specifically designed for long-lived connections like this, but with the right libraries — especially Ratchet, built on top of ReactPHP — PHP can become a reliable WebSocket server. This article covers the WebSocket protocol from the low level (handshake and framing), how to build a simple server from scratch to understand the mechanics, and then using Ratchet for production implementation.

How WebSocket Works #

WebSocket starts as an ordinary HTTP request that is then “upgraded” into a WebSocket connection. After the upgrade succeeds, the connection stays open and both sides can freely send messages.

sequenceDiagram
    participant Browser as Browser (Client)
    participant Server as PHP WebSocket Server

    Browser->>Server: HTTP GET /ws\nUpgrade: websocket\nConnection: Upgrade\nSec-WebSocket-Key: abc123
    Server->>Browser: HTTP 101 Switching Protocols\nUpgrade: websocket\nSec-WebSocket-Accept: xyz789

    Note over Browser,Server: WebSocket connection open — full duplex

    Browser->>Server: Frame: "Hello Server!"
    Server->>Browser: Frame: "Hello Client!"
    Server->>Browser: Frame: "Data update: {...}" (server push)
    Browser->>Server: Frame: "PING"
    Server->>Browser: Frame: "PONG"

    Browser->>Server: Close Frame
    Server->>Browser: Close Frame
    Note over Browser,Server: Connection closed

The WebSocket Protocol from the Low Level #

Before using a library, understanding the WebSocket protocol makes debugging much easier.

The Handshake #

<?php
// The client sends a request like this:
/*
GET /ws HTTP/1.1
Host: localhost:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
*/

// The server must reply with the correct accept key
function generateAcceptKey(string $clientKey): string
{
    // The magic string from RFC 6455
    $magic    = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
    $combined = $clientKey . $magic;
    $hash     = sha1($combined, binary: true); // SHA1 in binary format
    return base64_encode($hash);
}

// Example:
// clientKey = "dGhlIHNhbXBsZSBub25jZQ=="
// acceptKey = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

function sendHandshakeResponse($socket, string $clientKey): void
{
    $acceptKey = generateAcceptKey($clientKey);

    $response = implode("\r\n", [
        'HTTP/1.1 101 Switching Protocols',
        'Upgrade: websocket',
        'Connection: Upgrade',
        'Sec-WebSocket-Accept: ' . $acceptKey,
        '',
        '',
    ]);

    fwrite($socket, $response);
}

WebSocket Frames #

After the handshake, data is sent in WebSocket frame format — not plain HTTP text:

<?php
/**
 * WebSocket frame format (simplified):
 *
 * Byte 0:  FIN (1 bit) + RSV (3 bits) + Opcode (4 bits)
 * Byte 1:  Mask (1 bit) + Payload Length (7 bits)
 * Bytes 2-3 or 2-7: Extended payload length (if needed)
 * 4 bytes masking key (if from the client — clients always mask)
 * Payload data (XOR'd with the masking key if masked)
 */

// Decode a frame coming from the client (always masked)
function decodeFrame(string $data): ?string
{
    if (strlen($data) < 2) return null;

    $byte0   = ord($data[0]);
    $byte1   = ord($data[1]);

    // $fin     = ($byte0 & 0x80) !== 0; // FIN bit
    $opcode  = $byte0 & 0x0F; // 0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong
    $masked  = ($byte1 & 0x80) !== 0;
    $length  = $byte1 & 0x7F;

    $offset = 2;

    // Extended payload length
    if ($length === 126) {
        $length = unpack('n', substr($data, $offset, 2))[1];
        $offset += 2;
    } elseif ($length === 127) {
        $length = unpack('J', substr($data, $offset, 8))[1];
        $offset += 8;
    }

    // Masking key (4 bytes) — clients always mask data
    $maskKey = '';
    if ($masked) {
        $maskKey = substr($data, $offset, 4);
        $offset += 4;
    }

    // Payload
    $payload = substr($data, $offset, $length);

    // Unmask the payload
    if ($masked && $maskKey !== '') {
        for ($i = 0; $i < strlen($payload); $i++) {
            $payload[$i] = chr(ord($payload[$i]) ^ ord($maskKey[$i % 4]));
        }
    }

    // Handle the opcode
    return match($opcode) {
        0x1 => $payload,      // Text frame
        0x2 => $payload,      // Binary frame
        0x8 => null,          // Close frame
        0x9 => 'PING',        // Ping
        0xA => 'PONG',        // Pong
        default => null,
    };
}

// Encode a frame to send to the client (server doesn't mask)
function encodeFrame(string $payload, int $opcode = 0x1): string
{
    $length = strlen($payload);

    // First byte: FIN=1, RSV=0, opcode
    $frame   = chr(0x80 | $opcode);

    // Second byte onwards: payload length (not masked from the server)
    if ($length < 126) {
        $frame .= chr($length);
    } elseif ($length < 65536) {
        $frame .= chr(126) . pack('n', $length);
    } else {
        $frame .= chr(127) . pack('J', $length);
    }

    return $frame . $payload;
}

WebSocket Server from Scratch #

Using stream_select from the Socket article for a multi-client server, now with WebSocket handshake added:

<?php
declare(strict_types=1);

$server  = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
stream_set_blocking($server, false);

$clients    = [];  // [id => socket]
$handshaked = [];  // [id => bool]
$buffer     = [];  // [id => string]

echo "WebSocket Server running at ws://localhost:8080\n";

while (true) {
    $read    = array_merge([$server], $clients);
    $write   = null;
    $except  = null;

    stream_select($read, $write, $except, 0, 100_000);

    // New connection
    if (in_array($server, $read, true)) {
        $client = stream_socket_accept($server, 0);
        if ($client) {
            $id               = (int) $client;
            $clients[$id]     = $client;
            $handshaked[$id]  = false;
            $buffer[$id]      = '';
            stream_set_blocking($client, false);
            echo "[$id] New connection\n";
        }
    }

    // Data from clients
    foreach ($clients as $id => $client) {
        if (!in_array($client, $read, true)) continue;

        $data = fread($client, 65536);

        if ($data === false || $data === '') {
            closeClient($id, $clients, $handshaked, $buffer);
            continue;
        }

        $buffer[$id] .= $data;

        if (!$handshaked[$id]) {
            // Try to perform the handshake
            if (str_contains($buffer[$id], "\r\n\r\n")) {
                $key = extractWebSocketKey($buffer[$id]);
                if ($key) {
                    sendHandshakeResponse($client, $key);
                    $handshaked[$id] = true;
                    $buffer[$id]     = '';
                    echo "[$id] Handshake successful\n";

                    // Send a welcome message
                    fwrite($client, encodeFrame(json_encode([
                        'type' => 'welcome',
                        'id'   => $id,
                    ])));
                } else {
                    closeClient($id, $clients, $handshaked, $buffer);
                }
            }
        } else {
            // Process WebSocket frames
            while (strlen($buffer[$id]) >= 2) {
                $message = decodeFrame($buffer[$id]);

                if ($message === null) {
                    closeClient($id, $clients, $handshaked, $buffer);
                    break;
                }

                // Calculate the processed frame length
                // (simple implementation — assume the frame is complete)
                $buffer[$id] = '';

                echo "[$id] Message: $message\n";

                // Broadcast to all handshaked clients
                $payload = json_encode(['from' => $id, 'message' => $message]);
                foreach ($clients as $otherId => $other) {
                    if ($handshaked[$otherId]) {
                        fwrite($other, encodeFrame($payload));
                    }
                }
            }
        }
    }
}

function extractWebSocketKey(string $request): ?string
{
    if (preg_match('/Sec-WebSocket-Key:\s*(.+)\r\n/i', $request, $m)) {
        return trim($m[1]);
    }
    return null;
}

function closeClient(int $id, array &$clients, array &$handshaked, array &$buffer): void
{
    fclose($clients[$id]);
    unset($clients[$id], $handshaked[$id], $buffer[$id]);
    echo "[$id] Connection closed\n";
}

Ratchet — WebSocket Server for Production #

Writing a WebSocket server from scratch is great for learning, but for production use Ratchet — a PHP WebSocket library built on ReactPHP that handles all the protocol details, ping/pong, and dropped connections.

composer require cboden/ratchet

The MessageComponentInterface #

Ratchet uses the MessageComponentInterface which defines four events:

<?php
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

interface MessageComponentInterface
{
    // A new connection opened
    public function onOpen(ConnectionInterface $conn): void;

    // A message received from the client
    public function onMessage(ConnectionInterface $from, string $msg): void;

    // Connection closed (normal or error)
    public function onClose(ConnectionInterface $conn): void;

    // Error on the connection
    public function onError(ConnectionInterface $conn, \Exception $e): void;
}

A Simple Chat Room Implementation #

<?php
// src/ChatRoom.php
namespace App;

use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class ChatRoom implements MessageComponentInterface
{
    // SplObjectStorage — stores the list of active connections
    private \SplObjectStorage $clients;
    private array $usernames = []; // [resourceId => username]

    public function __construct()
    {
        $this->clients = new \SplObjectStorage();
        echo "ChatRoom ready\n";
    }

    public function onOpen(ConnectionInterface $conn): void
    {
        $this->clients->attach($conn);
        $connId = $conn->resourceId;

        echo "New connection: #{$connId} ({$this->clients->count()} total)\n";

        // Send the list of existing users
        $conn->send(json_encode([
            'type'  => 'info',
            'message' => "Welcome! There are " . ($this->clients->count() - 1) . " other users.",
        ]));
    }

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

        if (!is_array($data) || !isset($data['type'])) {
            $from->send(json_encode(['type' => 'error', 'message' => 'Invalid message format']));
            return;
        }

        match($data['type']) {
            'join'    => $this->handleJoin($from, $data),
            'message' => $this->handleMessage($from, $data),
            'typing'  => $this->broadcastTyping($from),
            default   => $from->send(json_encode(['type' => 'error', 'message' => 'Unknown type'])),
        };
    }

    private function handleJoin(ConnectionInterface $conn, array $data): void
    {
        $username = htmlspecialchars(trim($data['username'] ?? 'Anonymous'), ENT_QUOTES, 'UTF-8');
        $this->usernames[$conn->resourceId] = $username;

        echo "#{$conn->resourceId} joined as '$username'\n";

        // Tell all users someone joined
        $this->broadcast(json_encode([
            'type'     => 'join',
            'username' => $username,
            'users'    => array_values($this->usernames),
        ]), except: $conn);

        // Confirm to the joining user
        $conn->send(json_encode([
            'type'     => 'joined',
            'username' => $username,
            'users'    => array_values($this->usernames),
        ]));
    }

    private function handleMessage(ConnectionInterface $from, array $data): void
    {
        $username = $this->usernames[$from->resourceId] ?? 'Anonymous';
        $content  = htmlspecialchars(trim($data['content'] ?? ''), ENT_QUOTES, 'UTF-8');

        if (empty($content)) return;

        echo "$username: $content\n";

        $payload = json_encode([
            'type'      => 'message',
            'username'  => $username,
            'content'   => $content,
            'timestamp' => time(),
        ]);

        // Send to all clients including the sender
        $this->broadcast($payload);
    }

    private function broadcastTyping(ConnectionInterface $from): void
    {
        $username = $this->usernames[$from->resourceId] ?? 'Anonymous';
        $this->broadcast(json_encode([
            'type'     => 'typing',
            'username' => $username,
        ]), except: $from);
    }

    private function broadcast(string $message, ?ConnectionInterface $except = null): void
    {
        foreach ($this->clients as $client) {
            if ($client !== $except) {
                $client->send($message);
            }
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        $username = $this->usernames[$conn->resourceId] ?? 'Anonymous';
        $this->clients->detach($conn);
        unset($this->usernames[$conn->resourceId]);

        echo "#{$conn->resourceId} ($username) left the chat\n";

        $this->broadcast(json_encode([
            'type'     => 'leave',
            'username' => $username,
            'users'    => array_values($this->usernames),
        ]));
    }

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

Running the Ratchet Server #

<?php
// server.php
require __DIR__ . '/vendor/autoload.php';

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

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

echo "WebSocket server running at ws://localhost:8080\n";
$server->run();
# Run the server in a terminal
php server.php

# For production, run it with supervisor
# /etc/supervisor/conf.d/websocket.conf
# [program:php-websocket]
# command=php /var/www/app/server.php
# autostart=true
# autorestart=true

JavaScript Client for Connecting to the Server #

<!DOCTYPE html>
<html>
<head><title>WebSocket Chat</title></head>
<body>
<div id="messages"></div>
<input id="input" type="text" placeholder="Type a message...">
<button onclick="send()">Send</button>

<script>
// Create the WebSocket connection
const ws = new WebSocket('ws://localhost:8080');

ws.onopen = () => {
    console.log('Connected to server');
    // Join with a name
    ws.send(JSON.stringify({
        type: 'join',
        username: 'Budi'
    }));
};

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    const div  = document.getElementById('messages');

    switch(data.type) {
        case 'message':
            div.innerHTML += `<p><b>${data.username}:</b> ${data.content}</p>`;
            break;
        case 'join':
            div.innerHTML += `<p><i>${data.username} joined</i></p>`;
            break;
        case 'leave':
            div.innerHTML += `<p><i>${data.username} left</i></p>`;
            break;
        case 'typing':
            document.title = `${data.username} is typing...`;
            setTimeout(() => document.title = 'Chat', 2000);
            break;
    }
};

ws.onclose = () => console.log('Disconnected from server');
ws.onerror = (e) => console.error('Error:', e);

function send() {
    const input = document.getElementById('input');
    if (input.value.trim()) {
        ws.send(JSON.stringify({
            type: 'message',
            content: input.value.trim()
        }));
        input.value = '';
    }
}

// Send a typing signal when the user types
document.getElementById('input').addEventListener('keypress', () => {
    ws.send(JSON.stringify({ type: 'typing' }));
});
</script>
</body>
</html>

WebSocket Connection Authentication #

A WebSocket handshake is an ordinary HTTP request — you can check cookies, tokens, or query string parameters before allowing the connection:

<?php
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Http\HttpServerInterface;
use Psr\Http\Message\RequestInterface;

class AuthenticatedChat implements HttpServerInterface
{
    private ChatRoom $chat;

    public function __construct(ChatRoom $chat)
    {
        $this->chat = $chat;
    }

    // Called when an HTTP request comes in (before the WebSocket upgrade)
    public function onOpen(ConnectionInterface $conn, RequestInterface $request = null): void
    {
        // Get the token from the query string: ws://localhost:8080/?token=xxx
        $query = $request->getUri()->getQuery();
        parse_str($query, $params);
        $token = $params['token'] ?? '';

        // Validate the token
        $user = $this->validateToken($token);

        if ($user === null) {
            // Reject the connection — send 401 and close
            $conn->send("HTTP/1.1 401 Unauthorized\r\n\r\n");
            $conn->close();
            echo "Connection rejected — invalid token\n";
            return;
        }

        // Store the user info on the connection
        $conn->user = $user;
        $this->chat->onOpen($conn);
    }

    private function validateToken(string $token): ?array
    {
        if (empty($token)) return null;

        // Here you'd validate a JWT or look up in a database/Redis
        // Simple example:
        $data = base64_decode($token);
        $user = json_decode($data, true);

        return is_array($user) && isset($user['id']) ? $user : null;
    }

    public function onMessage(ConnectionInterface $from, $msg): void
    {
        $this->chat->onMessage($from, $msg);
    }

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

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

Targeted Broadcast — Rooms and Channels #

Instead of broadcasting to all clients, you often need to send to a subset — for example in an application with many chat rooms:

<?php
class RoomManager implements MessageComponentInterface
{
    private \SplObjectStorage $clients;
    private array $rooms = []; // [roomId => [connId => conn]]

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

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

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

        match($data['type'] ?? '') {
            'join_room'   => $this->joinRoom($from, $data['room_id']),
            'leave_room'  => $this->leaveRoom($from, $data['room_id']),
            'send_room'   => $this->sendToRoom($from, $data['room_id'], $data['message']),
            'broadcast'   => $this->broadcastGlobal($data['message']),
            default       => null,
        };
    }

    private function joinRoom(ConnectionInterface $conn, string $roomId): void
    {
        $this->rooms[$roomId][$conn->resourceId] = $conn;
        $conn->rooms[]                            = $roomId;

        $this->sendToRoom(null, $roomId, [
            'event'     => 'user_joined',
            'room_id'   => $roomId,
            'count'     => count($this->rooms[$roomId]),
        ]);
    }

    private function leaveRoom(ConnectionInterface $conn, string $roomId): void
    {
        unset($this->rooms[$roomId][$conn->resourceId]);
        $conn->rooms = array_filter($conn->rooms, fn($r) => $r !== $roomId);

        if (empty($this->rooms[$roomId])) {
            unset($this->rooms[$roomId]);
        }
    }

    private function sendToRoom(?ConnectionInterface $sender, string $roomId, mixed $message): void
    {
        if (!isset($this->rooms[$roomId])) return;

        $payload = is_string($message) ? $message : json_encode($message);

        foreach ($this->rooms[$roomId] as $connId => $conn) {
            if ($conn !== $sender) {
                $conn->send($payload);
            }
        }
    }

    private function broadcastGlobal(mixed $message): void
    {
        $payload = is_string($message) ? $message : json_encode($message);
        foreach ($this->clients as $client) {
            $client->send($payload);
        }
    }

    public function onClose(ConnectionInterface $conn): void
    {
        // Remove from all rooms
        foreach ($conn->rooms as $roomId) {
            $this->leaveRoom($conn, $roomId);
        }
        $this->clients->detach($conn);
    }

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

When to Use WebSocket and When to Use Alternatives #

WebSocket isn’t always the best answer. Several alternatives are often more appropriate:

NeedBest Solution
Push notifications from the serverServer-Sent Events (SSE) — simpler, one-way
Periodic data updatesRegular polling or Long Polling
Chat, games, real-time collaborationWebSocket
Live dashboards with occasional updatesSSE or polling every 5 seconds
File upload progressSSE or XHR progress events
Thousands of concurrent connectionsWebSocket + ReactPHP or Node.js
Use WebSocket when:
  ✓ You need two-way communication (both client and server initiate messages)
  ✓ Data is sent frequently and quickly (> 1 time per second)
  ✓ Low latency is critical (games, trading)
  ✓ The server needs to push data without the client asking

Avoid WebSocket when:
  ✗ You only need one-way server push → use SSE
  ✗ Updates are infrequent (every minute) → use regular polling
  ✗ Only a single request-response → plain HTTP is far simpler
  ✗ The infrastructure doesn't support long-lived connections (shared hosting)

Summary #

  • WebSocket starts as HTTP that gets upgraded — the handshake uses Sec-WebSocket-Key hashed with the RFC 6455 magic string to produce Sec-WebSocket-Accept.
  • WebSocket frames have a special binary format — FIN bit, opcode, masking, and payload length. Clients always mask data to the server; the server doesn’t mask data to clients.
  • Ratchet is the best PHP WebSocket library for production — built on the ReactPHP event loop, handling all protocol details. Implement MessageComponentInterface with four methods: onOpen, onMessage, onClose, onError.
  • SplObjectStorage is the right data structure for storing the list of active connections — efficient iteration, easy attach/detach.
  • Authentication happens during the HTTP upgrade request — check a token from the query string or cookies before accepting the connection. Reject by closing the connection if invalid.
  • Rooms and channels enable targeted broadcasting to client subsets — store a roomId => [connId => conn] mapping and iterate only the connections in the relevant room.
  • Server-Sent Events (SSE) is a simpler alternative for one-way server-to-client push — no special library needed, just HTTP with Content-Type: text/event-stream.
  • Run the WebSocket server as a daemon with Supervisor for auto-restart on crash. Use Nginx as a reverse proxy forwarding WebSocket connections to the PHP server.

← Previous: Sockets   Next: Web Server →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact