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 closedThe 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:
| Need | Best Solution |
|---|---|
| Push notifications from the server | Server-Sent Events (SSE) — simpler, one-way |
| Periodic data updates | Regular polling or Long Polling |
| Chat, games, real-time collaboration | WebSocket |
| Live dashboards with occasional updates | SSE or polling every 5 seconds |
| File upload progress | SSE or XHR progress events |
| Thousands of concurrent connections | WebSocket + 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-Keyhashed with the RFC 6455 magic string to produceSec-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
MessageComponentInterfacewith four methods:onOpen,onMessage,onClose,onError.SplObjectStorageis 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.