gos/web-socket-bundle
A Symfony bundle for adding real-time WebSocket features to your app. It provides a Ratchet-based server, topic-based publish/subscribe, RPC, authentication, and Symfony service integration for easy bidirectional communication.
Install and wire it up (no Flex recipe — do this by hand):
composer require gos/web-socket-bundle
Symfony 4/5/6: register Gos\Bundle\WebSocketBundle\GosWebSocketBundle in config/bundles.php (if not auto-registered), then create config/packages/gos_web_socket.yaml:
gos_web_socket:
server:
host: 127.0.0.1
port: 8080
# topic/rpc/periodic services tagged below are picked up automatically
Write one Topic — this is the unit of work for everything the client subscribes to:
namespace App\WebSocket;
use Gos\Bundle\WebSocketBundle\Topic\TopicInterface;
use Ratchet\ConnectionInterface;
use Ratchet\Wamp\Topic;
class ChatTopic implements TopicInterface
{
public function onOpen(ConnectionInterface $connection): void {}
public function onClose(ConnectionInterface $connection): void {}
public function onSubscribe(ConnectionInterface $connection, Topic $topic): void
{
$topic->broadcast(['msg' => $connection->resourceId . ' joined']);
}
public function onUnsubscribe(ConnectionInterface $connection, Topic $topic): void {}
public function onPublish(ConnectionInterface $connection, Topic $topic, $event, array $exclude, array $eligible): void
{
$topic->broadcast($event);
}
public function getName(): string
{
return 'chat'; // client subscribes to "chat"
}
}
Register it (autoconfigure handles the tag if you use services.yaml defaults):
App\WebSocket\ChatTopic:
tags: ['gos_web_socket.topic']
Run the daemon:
php bin/console gos:websocket:server -vvv
Connect from the browser. The bundle ships an Autobahn.JS-based client under Resources/public/js. Run php bin/console assets:install --symlink and include @GosWebSocketBundle/client.js (or pull Autobahn from a CDN yourself), then:
const socket = gosSocket(); // or new GosSocket({...})
socket.connect('ws://127.0.0.1:8080');
socket.subscribe('chat', (data) => console.log(data.msg));
First use case to prove the wiring: a notifications channel. On onSubscribe, inject ClientManipulatorInterface, look up the user from the session ($connection->Session), and $topic->broadcast() a "hello" — you've now validated auth, topic routing and the daemon in one pass.
Where to look first: read TopicInterface and RpcInterface in src/, then Server/Type/WebSocketServer.php (the daemon) and Resources/doc/ (the docs are actually thorough). Run debug:config gos_web_socket and debug:container gos_web_socket to see the real service IDs and keys for your installed version — they changed across 2.x → 3.x.
Keep Topics thin, delegate to domain services. A Topic is a transport adapter, not a place for business logic. Inject your existing service (NotificationService, MessageRepository) and have onPublish validate and hand off. This is what makes the websocket code testable — you can unit-test the Topic with a mocked ConnectionInterface and Topic.
Push from the HTTP side with the pusher. Anything happening in a controller, console command, or Doctrine event listener can reach connected clients:
use Gos\Bundle\WebSocketBundle\Pusher\PusherInterface;
class OrderController
{
public function __construct(private PusherInterface $pusher) {}
public function ship(Order $order)
{
$this->pusher->push(
['orderId' => $order->getId(), 'status' => 'shipped'],
'orders' // topic name
);
}
}
The pusher sends over WAMP (HTTP to the running daemon) or ZMQ depending on gos_web_socket.pusher config. For local dev the WAMP driver is fine; for anything with more than one app server, switch to the ZMQ driver so each PHP-FPM node can publish into the socket process without holding a WAMP connection open.
Target a specific user, not a topic. Inject ClientManipulatorInterface inside the daemon to map users ↔ connections:
foreach ($this->clientManipulator->getAll() as $client) {
if ($client->getUser()->getId() === $userId) {
$client->getConnection()->send(json_encode($payload));
}
}
This is the pattern for private messages and "you have a new notification" badges.
Bridge Symfony events to the socket. A tiny listener on kernel.terminate (or a Messenger handler) that collects domain events and calls the pusher keeps websocket concerns out of your entities. Don't push inside postPersist synchronously — a slow/absent daemon will hang the request.
Use RPC for request/response, Topics for fan-out. Implement RpcInterface (getName() + call(ConnectionInterface $conn, $params)) when the client needs a result (e.g. "load last 50 messages on subscribe"). The JS side is socket.call('chat', 'history', {since: ts}, cb).
Session-based auth is the default path. Configure the session handler in gos_web_socket.clients / firewall name, then in onSubscribe read $connection->Session (a SessionProviderInterface implementation). This lets you authorize per-topic at subscribe time and reject with $connection->close() — do that before adding the connection to the topic.
Periodic services for heartbeats/presence. Implement PeriodicInterface and tag gos_web_socket.periodic; run() fires every tick. Use it to sweep stale presence entries or ping your DB, not for per-user logic.
Client-side pattern: subscribe once per page section, and always keep the returned subscription handle so you can unsubscribe() on teardown. Autobahn reconnects are your responsibility — wrap connect in a retry with backoff and re-subscribe on onopen.
.env reload. Development means Ctrl-C + gos:websocket:server again. Plan for that in your workflow.$em->getConnection()->close(); $em->getConnection()->connect();) or wrap queries in a reconnect retry.RequestStack is empty, TokenStorage is empty, kernel.request never fires. Anything you'd normally get from the request must come from $connection->Session or be passed in the message payload.onPublish, not just at subscribe.ws://host:8080 works; cross-origin or wss:// behind a proxy often doesn't, because the PHPSESSID cookie isn't sent. Serve the socket on a subdomain with the same cookie domain, or pass an explicit token in the subscribe payload.proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; plus proxy_read_timeout well above your heartbeat interval, or connections die silently after 60s.Origin header doesn't match the configured host — a classic "works with wscat, fails in Chrome" bug. Verify via debug:config gos_web_socket and the -vvv daemon log.gos_web_socket.wamp.pusher vs gos_web_socket.pusher, gos_web_socket.client.manipulator vs ClientManipulatorInterface — always resolve by interface (PusherInterface, ClientManipulatorInterface) via constructor injection rather than by string ID.-vvv; the log tells you handshake rejections, topic name mismatches and pusher failures. ConnectionInterface::$resourceId and $connection->WAMP->sessionId are your only identifiers — log them on open/close to trace client churn.How can I help you explore Laravel packages today?