Skip to content
LibxaFrame

LibxaSocket

Realtime that Laravel Echo already speaks.

Version
0.1.0
Category
Realtime
Requires
PHP 8.3, libxa/framework ^0.11.2
Install
composer require libxa/socket

A WebSocket server for LibxaFrame that implements the Pusher protocol.

That is the whole design decision, and everything else follows from it: Laravel Echo connects to this server unchanged, so does pusher-js, and so does pusher/pusher-php-server publishing to it. Nothing on the browser side is LibxaSocket-specific.

composer require libxa/socket
php libxa package:discover
php libxa socket:install
php libxa socket:start

Why the protocol rather than a better one

A server with a protocol of its own needs a client of its own. That client has to handle reconnection, backoff, channel state across a reconnect, presence membership, and every browser's idea of when a socket is really dead — all of which already exists, tested by a very large number of people, and none of which can be used unless the bytes on the wire match.

The bytes match.

Why ReactPHP

Laravel Reverb is the reference implementation of this in PHP, so the question worth answering was what it runs on rather than what one assumes. Its composer.json requires react/socket, ratchet/rfc6455 and guzzlehttp/psr7. There is no Workerman in it.

This uses the same stack — react/socket for the event loop and listener, ratchet/rfc6455 for the handshake and frame codec.

Setting up

socket:install publishes config/socket.php, generates an app key and secret into .env, and scaffolds routes/channels.php.

SOCKET_APP_ID=app-34a5cfa4
SOCKET_APP_KEY=64deaac831685384e5bf662ce709d2fd
SOCKET_APP_SECRET=…
SOCKET_HOST=0.0.0.0
SOCKET_PORT=8080

BROADCAST_DRIVER=socket

The key is public and the secret is not. The key ships in your JavaScript and identifies which application a browser is connecting to. The secret signs channel authorizations and the publishing API; anyone holding it can send any message to any of your users.

Channels

Three kinds, told apart by the name prefix:

Prefix Who may subscribe
(none) anyone
private- anyone your application signs for
presence- the same, and they appear in a roster

The prefix is the entire rule. There is no separate registry saying which channels are protected, which means a channel cannot be left open by forgetting to declare it — naming it private- is the declaration.

Who may listen

routes/channels.php decides:

/** @var \LibxaSocket\Channels\ChannelGate $channel */

$channel->register('orders.{orderId}', fn ($user, string $orderId): bool =>
    Order::find($orderId)?->user_id === $user->id);

$channel->register('room.{roomId}', fn ($user, string $roomId): array => [
    'user_id' => (string) $user->id,
    'user_info' => ['name' => $user->name],
]);

Register the rule once, without the prefix: room.{roomId} covers both private-room.1 and presence-room.1.

A channel with no rule is refused. The alternative allows what nobody has written a rule for, which makes every private channel public until somebody remembers it exists — and nothing tells you which ones those are.

Whatever a presence callback returns is visible to everyone else in the channel. It should carry a display name and nothing more.

If your realtime identity is not your login — an anonymous support chat keyed on a session, a device rather than a person — replace the resolver:

$channel->resolveUserUsing(fn () => session()?->get('visitor'));

Without it those applications cannot use presence channels at all, since every subscription would be refused for want of a user.

Broadcasting

final class OrderShipped implements ShouldBroadcast
{
    public function __construct(public readonly Order $order) {}

    public function broadcastOn(): array
    {
        return ['private-orders.' . $this->order->id];
    }

    public function broadcastWith(): array
    {
        return ['status' => $this->order->status];
    }

    public function broadcastAs(): string
    {
        return 'OrderShipped';
    }
}
broadcast(new OrderShipped($order));

Delivery is best-effort on purpose. A socket server that is down must not take an HTTP request down with it: the order was placed, and the live update not arriving is worth logging rather than a 500. Anything that genuinely cannot lose an event needs a queue, not a tighter timeout.

In the browser

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_SOCKET_APP_KEY,
    wsHost: window.location.hostname,
    wsPort: 8080,
    forceTLS: false,
    enabledTransports: ['ws'],
});

Echo.join(`room.${roomId}`)
    .here(users => console.log(users))
    .joining(user => console.log(user.name, 'joined'))
    .leaving(user => console.log(user.name, 'left'))
    .listen('MessagePosted', e => console.log(e.body));

examples/chat in the repository is a working room — presence, live messages and typing indicators — written against the raw protocol rather than Echo, so every message the wire format involves is visible in one file.

Presence counts people, not connections

One user with two tabs open is one member of the room. Opening the second tab announces nothing; closing it announces nothing. Only the last one leaving sends member_removed.

This is the bug every presence implementation has first, and it is the one users notice, because "3 people online" being wrong is visible in a way most bugs are not.

The publishing API

Pusher's routes, signed with Pusher's scheme:

POST /apps/{id}/events
GET  /apps/{id}/channels
GET  /apps/{id}/channels/{channel}
GET  /apps/{id}/channels/{channel}/users
GET  /up                                  health, unsigned

Everything but /up requires a signature. An unauthenticated publish endpoint is a way for anyone who can reach the port to send any message to any of your users.

Security

  • Signatures cover the socket id, so one minted for a connection cannot be replayed by another. A token leaked out of one browser is useless from another.
  • Presence channel_data is verified byte-for-byte as sent, not re-encoded from the decoded value. Re-encoding produces different JSON — different key order, different escaping — so correct signatures start failing, and the natural fix for signatures that fail for no reason is to stop checking them. Tampering with the data to join as somebody else fails the signature.
  • The API signature covers the request body, so a captured publish cannot be edited and replayed, and carries a timestamp, so it cannot be replayed at all after ten minutes.
  • pusher: and pusher_internal: names are reserved on the publishing API. A forged member_added would corrupt every roster listening.
  • Client events are private and presence only. A public channel anyone can join is one anyone could publish to, which is a spam relay.

Running it

php libxa socket:start                 # foreground, Ctrl+C to stop
php libxa socket:start --port=8090     # somewhere else
php libxa socket:start --debug         # log every connection and message
php libxa socket:restart               # ask a running server to stop

The server holds every connection in memory and loads your code once at boot, so deploying does not reach it: until it restarts it keeps running the code it started with. socket:restart writes a signal the running server watches; it stops cleanly and whatever supervises it — systemd, supervisord, Docker — starts it again. On its own it stops the server and does not start it.

Behind TLS

This server speaks ws://, not wss://. A page served over HTTPS will refuse a ws:// connection outright, so in production put it behind a reverse proxy:

location /app {
    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_read_timeout 3600s;
}

proxy_read_timeout matters. The default is 60 seconds, and a WebSocket that is merely quiet looks exactly like one that has stalled.

What it does not do yet

One process, holding every connection and channel in memory. Two processes do not share channels, so a client connected to one will not receive an event published through the other.

For a single server this is usually fine — ReactPHP handles thousands of connections in one process and the work per message is small. Beyond that you need a shared backplane, which this does not have. Reverb solves it with Redis pub/sub, and the same approach fits here.

Requires

PHP 8.3+ and libxa/framework ^0.11.2 — which contains the broadcast() fix and the BroadcastManager::extend() hook this package registers through. Both were found while building it.