voryx/thruway
Thruway is a PHP implementation of the WAMP v2 protocol, providing a router and client built on ReactPHP. It enables WebSocket-based pub/sub and RPC for real-time PHP applications, with authentication and flexible transport support.
Thruway is a WAMP v2 implementation for PHP built on ReactPHP: it gives you PubSub (topics) and RPC (procedures) over WebSockets. It ships both halves — Thruway\Connection (client, on top of Pawl) and Thruway\Peer\Router (router, on top of Ratchet) — so you can point it at Crossbar/other WAMP routers or run your own.
composer require voryx/thruway
Smallest useful thing — a client subscribing and publishing:
use Thruway\ClientSession;
use Thruway\Connection;
$connection = new Connection([
'realm' => 'realm1',
'url' => 'ws://127.0.0.1:9090',
]);
$connection->on('open', function (ClientSession $session) {
$session->subscribe('app/orders/created', function ($args, $argsKw, $details) {
// $args[0] is whatever the publisher put in position 0
});
$session->publish('app/orders/created', [['id' => 42]], [], ['acknowledge' => true]);
});
$connection->open(); // blocks: this runs the ReactPHP loop
Smallest useful thing — a router:
use Thruway\Peer\Router;
use Thruway\Transport\RatchetTransportProvider;
$router = new Router();
$router->addTransportProvider(new RatchetTransportProvider('0.0.0.0', 9090));
$router->start();
Where to look first, in order:
Thruway\ClientSession — subscribe, publish, register, call, getSessionId, getLoop. This is 90% of your day.Thruway\Connection — the lifecycle (open/close events, options array, authenticator).Thruway\Peer\Router + Thruway\Transport\RatchetTransportProvider — only if you host the router.Thruway\Logging\Logger — set this immediately, before anything else. See Gotchas.The realistic first use case in a Laravel app: a long-running Artisan command (Supervisor-managed) that registers WAMP procedures and subscribes to topics, while browser clients talk to the same router with AutobahnJS. You are not using this from a normal FPM HTTP request — the connection is a persistent socket with an event loop attached.
There is no first-party Laravel integration package. Wire it yourself:
class ThruwayServeCommand extends Command
{
protected $signature = 'thruway:serve {--realm=realm1} {--port=9090}';
public function handle(): int
{
$router = new Router();
$router->addTransportProvider(
new RatchetTransportProvider('0.0.0.0', (int) $this->option('port'))
);
$this->registerModules($router); // auth, custom RouterModuleInterface modules
$router->start(); // blocking; Supervisor restarts it
return self::SUCCESS;
}
}
Put connection options in config/thruway.php and inject them; don't hardcode URLs. Run under Supervisor with --timeout=0-style settings (no PHP max_execution_time issues in CLI, but you do need restart-on-deploy since code is loaded once).
The clean pattern is a Laravel event listener that publishes onto WAMP, plus a thin publisher held in the container:
class WampPublisher
{
public function __construct(private Connection $connection) {}
public function publish(string $topic, array $args, array $kwargs = []): PromiseInterface
{
return $this->connection->getSession()->publish($topic, $args, $kwargs, [
'acknowledge' => true, // required if you want the promise to mean anything
]);
}
}
// app/Listeners/PublishOrderCreated.php
public function handle(OrderCreated $event): void
{
$this->wamp->publish('app/orders/created', [
['id' => $event->order->id, 'total' => $event->order->total],
], ['tenant' => $event->order->tenant_id]);
}
Browser side (AutobahnJS) subscribes to app/orders/created and gets the same payload. WAMP argument/kwarg separation is genuinely useful here: positional args for the "shape" of the event, kwargs for filters (tenant, user_id).
Targeted delivery uses options rather than app-level filtering:
$session->publish('app/chat', [$msg], [], [
'exclude' => [$session->getSessionId()], // don't echo to self
'eligible' => $sessionIds, // only these sessions
'exclude_me' => true,
'disclose_me' => true, // reveal publisher identity to subscribers
]);
$session->register('app.orders.cancel', function ($args, $argsKw, $details) {
$order = Order::findOrFail($args[0]);
if (! $order->isCancellable()) {
throw new WampErrorException('app.error.not_cancellable', [$order->id]);
}
$order->cancel();
return new CallResult([$order->id], ['status' => 'cancelled']); // args + kwargs
});
Rules that matter:
return [$a, $b] gives the caller two args, not one array.new Thruway\CallResult($args, $kwargs) when you need keyword results.Thruway\Exception\WampErrorException($uri, $args, $kwargs) to send a proper WAMP ERROR frame with an application error URI. Anything else surfaces as a generic error and may log noisily.$details->caller only if the registration was made with ['disclose_caller' => true] and the router allows it.Client side:
$session->call('app.orders.cancel', [42], [], [], 5) // 5s timeout
->then(
fn ($result) => $result, // $result is an array of args
fn ($error) => report($error) // Thruway\ClientSession::call error
);
When a session needs to attach/detach a set of topics as a unit (e.g., "user entered the dashboard"), use Thruway\Subscription\SubscriptionGroup rather than tracking individual subscription IDs. In 0.5+/0.6 subscribe() and register() return promises resolving to a Subscription/Registration, so grab the handle with ->then() when you need it:
$group = new SubscriptionGroup('dashboard');
$session->subscribe('app.orders/created', $handler)->then(
fn ($subscription) => $subscription->addToGroup($group)
);
Router side:
$authMgr = new AuthenticationManager();
$authMgr->addAuthProvider(new WampCraAuthProvider([
'service-worker' => config('thruway.secret'),
]));
$router->setAuthenticationManager($authMgr);
Client side, pass the authenticator through the options array:
$connection = new Connection([
'realm' => 'realm1',
'url' => 'ws://127.0.0.1:9090',
'authid' => 'service-worker',
'authenticator' => new ClientWampCraAuthenticator('service-worker', $secret),
]);
TicketAuthProvider/ClientTicketAuthenticator is the simpler alternative when you just want to validate a bearer token you already issued from Laravel. For a ws:// endpoint exposed to browsers, terminate TLS at nginx and use wss:// on the client side — WAMP-CRA over plain ws:// sends the challenge response in the clear.
Keep Thruway at the edges: ClientSession handlers should do the minimum (validate, dispatch a job, publish). Don't run a Model::find() storm or a synchronous third-party HTTP call inside a handler — you're on the React event loop and every subscriber on that session waits.
Boot the router and client in one process and drive the loop manually instead of shelling out to a server:
$router = new Router();
$router->addTransportProvider(new RatchetTransportProvider('127.0.0.1', 9091));
$router->start(false); // attach to the default loop without running it
$connection = new Connection(['realm' => 'realm1', 'url' => 'ws://127.0.0.1:9091']);
$connection->on('open', function (ClientSession $session) use ($connection) {
// assertions...
$connection->close();
});
$connection->open(); // runs the loop, which already has the router on it
Logging is off by default. Thruway\Logging\Logger ships with a NullLogger. Every "my registration silently does nothing" story starts here. One line, early in your boot:
Logger::set(new \Monolog\Logger('thruway', [new \Monolog\Handler\StreamHandler('php://stderr')]));
publish() resolves truthlessly without acknowledge. The promise resolves as soon as the frame is written unless you pass ['acknowledge' => true]. If you're using the promise to detect delivery failure, you need that option — and even then it only confirms router acceptance, not subscriber processing.
The default logger hides protocol-level close reasons. When a session dies, you get close with a $reason that maps to WAMP close codes (wamp.error.no_such_realm, wamp.error.not_authorized, …). Log it verbatim; it's the fastest path to auth/realm misconfiguration.
Blocking calls freeze everything. sleep(), ->wait(), sync HTTP clients, DB::transaction with lock waits — all of it stalls the loop, and every session multiplexed on that process stalls with it. Use $session->getLoop()->addTimer() for delays, and push heavy work to a queue (dispatch() then publish from the worker).
Never resolve Connection from the container inside an HTTP request. It's a stateful socket; binding it as a singleton in a ServiceProvider and using it from FPM gives you a connection that may be closed, half-open, or shared across requests. Keep it inside the CLI worker/command.
Reconnection is your job. After a reconnect, the router has forgotten you: re-subscribe and re-register on every open, not just the first one. Put subscriptions/registrations in a bootSession(ClientSession $session) method called from the open handler — that's the only version that survives a network blip. Watch for duplicate registration if you reconnect without the old session being cleaned up.
Return shape is the #1 source of "why is my caller getting weird args". return ['a' => 1] gives one positional argument (an array); return [1, 2] gives two. Use CallResult for kwargs, and return null → empty args.
Uncaught exceptions in handlers can take down the worker. Wrap handlers; convert domain failures to WampErrorException so the caller gets a meaningful ERROR and the process keeps running.
Everything crosses the wire as JSON. No PHP objects, no DateTime instances (serialize explicitly), no resource handles. Binary payloads need base64 or a WAMP binary option — don't assume it "just works".
Result/argument IDs are 53-bit integers. Fine in PHP, but avoid hand-crafting IDs; let Thruway allocate them.
RatchetTransportProvider binds 0.0.0.0 and has no built-in rate limiting. Put it behind nginx (proxy_read_timeout raised well above your ping interval, or idle sockets get cut at 60s), and if you scale to multiple routers, remember WAMP state is per-router: you need sticky sessions or a cross-router bridge, or a subscription to the same topic on each instance.
Maintenance reality check. Last release was Feb 2022. It works, but you're pinning ratchet/pawl, react/event-loop, and ratchet/rfc6455 transitively — expect PHP 8.x deprecation noise (dynamic properties) and audit the lockfile when you bump. If you only need PubSub and are already on Laravel, Reverb/Pusher is the lower-risk default; reach for Thruway when you specifically want RPC to PHP workers over the same socket or WAMP interop with Crossbar/Autobahn.
Extension points worth knowing: implement Thruway\Module\RouterModuleInterface with initModule(Router $router) + getSubscribedEvents() to add router-side behavior (session tracking, procedure ACLs) without forking; custom AbstractAuthProvider subclasses for Laravel token validation; and the wamp.registration.* / wamp.session.* meta topics for admin dashboards — check which meta events your version actually emits before designing around them.
How can I help you explore Laravel packages today?