WebSocket

Introduction

WebSockets provide full-duplex, real-time communication between the server and connected clients. Unlike HTTP requests, which are stateless and short-lived, a WebSocket connection stays open indefinitely — the server can push data to clients at any time without waiting for a request.

Slenix includes a pure-PHP WebSocket server built on PHP's native socket extension, with zero external dependencies. It is ideal for chat applications, live notifications, dashboards, collaborative tools, and any feature that requires instant updates.


Starting the WebSocket Server

Together with the HTTP server

The simplest way to run both servers during development is to pass the --ws flag to serve:

bash
php celestial serve --ws

This starts the HTTP server on port 8080 and the WebSocket server on port 8081 simultaneously.

You can customise both ports:

bash
php celestial serve --ws --ws-port=9000

As a standalone server

For production or when you need more control, run the WebSocket server independently:

bash
php celestial ws:serve
php celestial ws:serve --port=9000
php celestial ws:serve --host=0.0.0.0 --port=9000

Running both in parallel (production pattern)

bash
# Terminal 1 — HTTP server (or your web server / reverse proxy)
php celestial serve

# Terminal 2 — WebSocket server
php celestial ws:serve --port=8081

Registering WebSocket Routes

WebSocket routes are registered in routes/web.php alongside your HTTP routes, using Router::websocket():

php
use Slenix\Http\Routing\Router;
use App\Controllers\ChatController;
use App\Controllers\NotificationController;

// HTTP routes
Router::get('/', [HomeController::class, 'index']);

// WebSocket routes
Router::websocket('/ws/chat',          ChatController::class);
Router::websocket('/ws/notifications', NotificationController::class);

Each path maps to a handler class that will be instantiated when the WebSocket server starts.


Creating a Handler

WebSocket handlers extend Slenix\Core\WebSocket\WebSocketHandler and override one or more lifecycle methods:

php
php celestial make:controller Chat

Then extend WebSocketHandler instead of a plain controller:

php
<?php

declare(strict_types=1);

namespace App\Controllers;

use Slenix\Core\WebSocket\WebSocketHandler;
use Slenix\Core\WebSocket\Connection;

class ChatController extends WebSocketHandler
{
    /**
     * Called when a new client connects.
     */
    public function onOpen(Connection $conn): void
    {
        echo "New connection: {$conn->getId()}" . PHP_EOL;

        $conn->send([
            'type'    => 'connected',
            'message' => 'Welcome to the chat!',
        ]);

        $this->broadcast([
            'type'    => 'user_joined',
            'message' => 'A new user joined.',
        ], except: $conn);
    }

    /**
     * Called when a message is received from a client.
     * $data is automatically decoded from JSON if the payload is valid JSON.
     */
    public function onMessage(Connection $conn, mixed $data): void
    {
        $type = $data['type'] ?? 'message';

        if ($type === 'message') {
            // Broadcast to everyone including the sender
            $this->broadcast([
                'type'      => 'message',
                'sender_id' => $conn->getId(),
                'text'      => $data['text'] ?? '',
                'time'      => date('H:i'),
            ]);
        }
    }

    /**
     * Called when a client disconnects.
     */
    public function onClose(Connection $conn): void
    {
        $this->broadcast([
            'type'    => 'user_left',
            'message' => 'A user disconnected.',
        ]);
    }

    /**
     * Called when an error occurs on a connection.
     */
    public function onError(Connection $conn, \Throwable $e): void
    {
        echo "Error on {$conn->getId()}: {$e->getMessage()}" . PHP_EOL;
    }
}

Sending Messages

Sending to a specific connection

php
$conn->send('Hello!');

// Arrays are automatically JSON-encoded
$conn->send([
    'type' => 'message',
    'text' => 'Hello!',
]);

Broadcasting to all connections

php
// Send to everyone on this handler
$this->broadcast(['type' => 'alert', 'text' => 'Server restarting in 60s.']);

// Send to everyone except the sender
$this->broadcast(['type' => 'message', 'text' => $data['text']], except: $conn);

Sending to a specific connection by ID

php
$this->sendTo($connectionId, [
    'type'    => 'private',
    'message' => 'Only for you.',
]);

Connection Attributes

You can store arbitrary data on a connection — useful for tracking the authenticated user, room membership, or any per-client state:

php
public function onOpen(Connection $conn): void
{
    // Store data on the connection
    $conn->setAttribute('user_id', 42);
    $conn->setAttribute('room',    'general');
}

public function onMessage(Connection $conn, mixed $data): void
{
    // Read it back later
    $userId = $conn->getAttribute('user_id');
    $room   = $conn->getAttribute('room', 'lobby');
}

Real-World Example — Chat with Rooms

php
<?php

declare(strict_types=1);

namespace App\Controllers;

use Slenix\Core\WebSocket\WebSocketHandler;
use Slenix\Core\WebSocket\Connection;

class ChatController extends WebSocketHandler
{
    public function onOpen(Connection $conn): void
    {
        $conn->setAttribute('room', 'general');
        $conn->setAttribute('username', 'Guest#' . rand(1000, 9999));

        $conn->send([
            'type'     => 'connected',
            'username' => $conn->getAttribute('username'),
            'room'     => $conn->getAttribute('room'),
            'online'   => $this->connectionCount(),
        ]);
    }

    public function onMessage(Connection $conn, mixed $data): void
    {
        $type = $data['type'] ?? '';

        // Change room
        if ($type === 'join_room') {
            $conn->setAttribute('room', $data['room']);
            $conn->send(['type' => 'room_joined', 'room' => $data['room']]);
            return;
        }

        // Set username
        if ($type === 'set_username') {
            $conn->setAttribute('username', htmlspecialchars($data['username']));
            return;
        }

        // Broadcast message to users in the same room
        if ($type === 'message') {
            $room = $conn->getAttribute('room');

            foreach ($this->getConnections() as $c) {
                if ($c->getAttribute('room') === $room) {
                    $c->send([
                        'type'     => 'message',
                        'username' => $conn->getAttribute('username'),
                        'text'     => htmlspecialchars($data['text'] ?? ''),
                        'room'     => $room,
                        'time'     => date('H:i'),
                    ]);
                }
            }
        }
    }

    public function onClose(Connection $conn): void
    {
        $this->broadcast([
            'type'     => 'user_left',
            'username' => $conn->getAttribute('username'),
            'online'   => $this->connectionCount() - 1,
        ]);
    }
}

JavaScript Client

Connect to your WebSocket server from the browser:

javascript
const ws = new WebSocket('ws://127.0.0.1:8081/ws/chat');

ws.onopen = () => {
    console.log('Connected!');
};

ws.onmessage = (event) => {
    const data = JSON.parse(event.data);

    if (data.type === 'message') {
        console.log(`[${data.time}] ${data.username}: ${data.text}`);
    }

    if (data.type === 'user_joined') {
        console.log('Someone joined the chat.');
    }
};

ws.onclose = () => {
    console.log('Disconnected.');
};

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

// Send a message
function sendMessage(text) {
    ws.send(JSON.stringify({ type: 'message', text }));
}

// Change room
function joinRoom(room) {
    ws.send(JSON.stringify({ type: 'join_room', room }));
}

Using Jobs and WebSocket Together

A common pattern is combining Jobs and WebSocket for real-time notifications after a background task completes. For example, after a user places an order, an email is sent in the background and the seller is notified in real time:

php
public function checkout(Request $request, Response $response): void
{
    $order = Order::create([...]);

    // Background jobs — do not block the response
    dispatch(new SendOrderConfirmationEmailJob($order));
    dispatch(new GenerateInvoicePdfJob($order));
    dispatch(new UpdateStockJob($order));

    $response->status(201)->json([
        'success' => true,
        'message' => 'Order placed successfully.',
        'data'    => $order,
    ]);
}

Inside GenerateInvoicePdfJob::handle(), after the PDF is ready, notify the client via WebSocket:

php
public function handle(): void
{
    $pdf = $this->generatePdf($this->order);

    // Notify the user's browser in real time
    WebSocketClient::send("/ws/notifications", $this->order->user_id, [
        'type'    => 'invoice_ready',
        'order'   => $this->order->id,
        'pdf_url' => $pdf->url(),
    ]);
}

Handler Method Reference

MethodWhen it is called
onOpen(Connection $conn)A new client connects and the handshake is complete
onMessage(Connection $conn, mixed $data)A message frame is received (JSON auto-decoded)
onClose(Connection $conn)A client disconnects
onError(Connection $conn, \Throwable $e)An exception is thrown inside a lifecycle method

Broadcasting Method Reference

MethodDescription
$this->broadcast($data)Send to all connected clients
$this->broadcast($data, except: $conn)Send to all except one connection
$this->sendTo($id, $data)Send to a specific connection by ID
$this->getConnections()Returns all active Connection objects
$this->connectionCount()Returns the number of active connections

Connection Method Reference

MethodDescription
$conn->getId()Returns the unique connection ID
$conn->send($data)Sends a message (string or array) to this client
$conn->close()Closes the connection
$conn->setAttribute($key, $value)Stores a value on the connection
$conn->getAttribute($key, $default)Retrieves a stored value
$conn->hasAttribute($key)Checks whether an attribute exists