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_datais 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:andpusher_internal:names are reserved on the publishing API. A forgedmember_addedwould 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.