libxa / socket
A Pusher-protocol WebSocket server for LibxaFrame, built on ReactPHP. Works with existing Pusher clients unchanged.
Requires
- php: ^8.3
- guzzlehttp/psr7: ^2.6
- libxa/framework: ^0.11.2
- ratchet/rfc6455: ^0.4
- react/promise-timer: ^1.10
- react/socket: ^1.14
- symfony/console: ^7.0
Requires (Dev)
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-15 08:38:27 UTC
README
A WebSocket server for LibxaFrame, speaking the Pusher protocol.
That last part is the point. Rather than inventing a wire format and shipping a
client to match, this implements the protocol Pusher defined — so pusher-js
and every other Pusher client work against it unchanged, and so do the server
libraries that publish to it.
Built on ReactPHP, the same foundation the reference PHP implementation of this
protocol uses: react/socket for the event loop and listener,
ratchet/rfc6455 for the handshake and framing.
composer require libxa/socket php libxa package:discover php libxa socket:install php libxa socket:start
What it does
- Public, private and presence channels. The name prefix is the rule —
private-andpresence-require a signature your application vouches for. - A presence roster that counts people rather than connections: one user with two tabs is one member, and closing one tab is not leaving.
- A signed HTTP API for publishing, at Pusher's own routes, so
pusher/pusher-php-servercan talk to it. - Client events (
client-*) relayed browser-to-browser on private and presence channels, for typing indicators and cursors. broadcast(new SomethingHappened)from your application, through a broadcast driver.
Setting up
socket:install publishes config/socket.php, generates a key and secret into
your .env, and scaffolds routes/channels.php.
Then set the broadcast driver:
BROADCAST_DRIVER=socket
Who may listen to what
routes/channels.php decides. A private or presence channel with no rule here
is refused — the alternative allows what nobody has thought about, which
makes every channel public until somebody remembers it exists.
/** @var \LibxaSocket\Channels\ChannelGate $channel */ // Only the owner may watch their order. $channel->register('orders.{orderId}', fn ($user, string $orderId): bool => Order::find($orderId)?->user_id === $user->id); // Presence: return the profile the rest of the room should see. $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.
Whatever a presence callback returns is visible to everyone else in the channel, so 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, say — replace the resolver:
$channel->resolveUserUsing(fn () => session()?->get('visitor'));
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.
Connecting a browser
npm install @libxa/echo
import { createEcho } from '@libxa/echo'; const echo = createEcho({ key: import.meta.env.VITE_SOCKET_APP_KEY }); 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));
@libxa/echo wraps the
standard Pusher client with the defaults filled in — several of which are
quietly wrong otherwise. The one worth knowing: the client prefixes
App.Events onto every name given to .listen(), while this server publishes
broadcastAs() unprefixed, so a correct-looking client receives nothing at
all, with no error.
You can wire a Pusher client up yourself if you prefer — the protocol is the protocol, and nothing here is LibxaSocket-specific, which is the point of implementing it rather than inventing one. The wrapper exists so that those defaults come from something tested against this server.
Wiring a Pusher client up directly works too. If you do, these are the
settings that matter: cluster: '', disableStats: true,
enabledTransports: ['ws'], and namespace: false — the last being the one
that silently breaks everything if you miss it.
examples/chat has a working room — presence, live messages and typing
indicators — written against the raw protocol rather than a client library, so
every message the wire format involves is visible in one file.
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.
In front of a browser on HTTPS
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
that terminates TLS:
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.
The HTTP 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 notes
- The key is public, 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.
- Signatures cover the socket id, so one minted for a connection cannot be replayed by another.
- Presence
channel_datais verified byte-for-byte as sent. Tampering with it to join as somebody else fails the signature. pusher:andpusher_internal:event 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.
Scaling
One process, holding every connection and channel in memory. That is a real limit: 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 yet. The usual answer is Redis pub/sub; it fits here and is the obvious next thing to build.
Requirements
PHP 8.3+, and libxa/framework ^0.11.2.
License
MIT.