Skip to content
LibxaFrame
Sign In
Release 13 August 2026 7 min read

Introducing LibxaSocket: realtime that Laravel Echo already speaks

A WebSocket server for LibxaFrame that implements the Pusher protocol, so Echo and pusher-js connect to it unchanged. Built on ReactPHP — the same stack Laravel Reverb uses, which is what checking rather than assuming turned up. Presence channels that count people rather than connections, a signed publishing API, and two framework bugs found on the way.

There is now a WebSocket server for LibxaFrame: libxa/socket.

It speaks the Pusher protocol. That sentence is the whole design, so it is worth being clear about what it buys: Laravel Echo connects to it unchanged, so does pusher-js, and so does every other client written against Pusher over the last decade. Nothing here is LibxaSocket-specific on the browser side.

composer require libxa/socket
php libxa socket:install
php libxa socket:start
Echo.join(`room.${roomId}`)
    .here(users => console.log(users))
    .joining(user => console.log(user.name, 'joined'))
    .listen('MessagePosted', e => console.log(e.body));

Why the protocol, and not a better one

The package existed before this release, built on Workerman with a wire format of its own. Its own notes recorded the consequence, and they are worth quoting because they are honest:

I did not reimplement the full Pusher wire protocol, which would break Laravel Echo on the client side.

A server with its own protocol needs its own client. That client needs 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 that already exists, tested by a very large number of people, and none of it can be used unless the bytes on the wire match.

So the bytes now match.

Which meant ReactPHP, not Workerman

Laravel Reverb is the reference implementation of this in PHP, and the obvious thing to check was what it runs on. Its composer.json requires react/socket, ratchet/rfc6455 and guzzlehttp/psr7. There is no Workerman in it anywhere.

So this uses the same stack: react/socket for the event loop and the listener, ratchet/rfc6455 for the handshake and the frame codec. Writing either by hand would have meant a second implementation of a specification that has a good one.

What it does

Public, private and presence channels, told apart by the name prefix. private- and presence- require a signature your application vouches for. The prefix is the entire rule — there is no separate registry of which channels are protected, which means a channel cannot be left open by forgetting to declare it. Naming it private- is the declaration.

A presence roster that counts people, not connections. One user with two tabs open is one member of the room, and closing one tab is not leaving. 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.

A signed publishing API at Pusher's own routes, so pusher/pusher-php-server can publish to it. The signature covers the request body, so a captured publish cannot be edited and replayed against the same channel, and carries a timestamp, so it cannot be replayed at all after ten minutes.

broadcast(new OrderShipped($order)) from anywhere in your application.

A heartbeat. Connections that vanish without a FIN — a laptop lid closing, a phone changing network — are pinged and dropped. Without it they stay in memory as members of every presence channel they joined, and a roster slowly fills up with people who left hours ago.

Authorisation is a decision, not a signature

The signature proves the server said yes. It does not decide it. Deciding happens in routes/channels.php:

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

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

Two more things worth knowing:

The signature covers 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 exactly as the client sent it, rather than 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.

Two framework bugs it found

Building a package against a framework is the best test of that framework, and this one turned up two things that had never worked.

broadcast(new SomethingHappened) was a fatal error. The helper called BroadcastManager::send(), a method that has never existed on that class. Every documented use of the helper raised Call to undefined method, which means nothing has ever been broadcast through it.

No package could register a broadcaster. A driver had to be a create<Name>Driver method on BroadcastManager, so the only way to add one was to edit the framework — which made broadcasting the single subsystem a package could not extend, and a realtime package the obvious thing that could not be written. BroadcastManager::extend() fixes that.

Both are in framework 0.11.2, which this package requires.

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; it is the obvious next thing.

It also speaks ws://, not wss://. A page on HTTPS will refuse a ws:// connection outright, so in production it goes behind a reverse proxy that terminates TLS. The README has the nginx block, including the proxy_read_timeout line that everybody forgets — the default is 60 seconds, and a WebSocket that is merely quiet looks exactly like one that has stalled.

Try it

examples/chat in the repository is a working room: presence, live messages and typing indicators. It is written against the raw protocol rather than Echo, so every message the wire format involves is visible in one file — which is a better introduction to what is actually happening than a library that hides it.

Full documentation is on Libxa News.

Keep reading