Symfony WebSocket Tutorial: Scalable Real-Time Architecture

Symfony WebSocket Tutorial: Scalable Real-Time Architecture

Sam Rollin
Sam Rollin
February 22, 2026
February 22, 2026

Mercure is Symfony's official real-time solution, and it works great for server-to-client push notifications. But what happens when you need true bidirectional communication? Chat applications with typing indicators, collaborative document editing, multiplayer games, or live trading dashboards all require clients to send data back through persistent connections, and that's exactly where Mercure falls short.

Prerequisites

Before getting started, make sure you have:

  • A working Symfony 6.x or 7.x application
  • Basic understanding of Symfony Messenger
  • Docker installed (for local Redis/RabbitMQ setup)
  • Familiarity with at least one of: Node.js, Python, or Go
  • Understanding of JWT authentication patterns

For the code examples in this guide, we'll use:

  • Python with FastAPI for the WebSocket gateway
  • Redis for pub/sub messaging
  • Symfony Messenger with Redis transport

Understanding Redis pub/sub Symfony integration is helpful but not required.

Understanding Why Mercure Isn't Enough

Before building an alternative, let's be clear about what Mercure does well and where it struggles. This section explains why a Mercure alternative Symfony approach is often necessary.

What Mercure handles:

  • Server-Sent Events (SSE) for pushing updates to browsers
  • Topic-based subscriptions with JWT authorization
  • Simple pub/sub patterns
  • Native Symfony integration

Where Mercure falls short:

No native client-to-server messaging means clients must use separate HTTP requests to send data. HTTP-based publishing only means all messages go through POST requests to the hub. SSE, not WebSocket protocol means no true full-duplex communication. No connection state management means no built-in presence detection or typing indicators.

For applications requiring sub-100ms latency in both directions, continuous state sync, or high-frequency bidirectional messaging, you need actual WebSockets. This is particularly relevant for Symfony real-time chat implementations.

The External Gateway Architecture

Working with various clients we've learned that the most reliable pattern separates WebSocket handling from Symfony entirely. Here's why this works: The WebSocket gateway architecture keeps connection management separate from business logic.

┌─────────────┐     WebSocket      ┌──────────────────┐
│   Browser   │◄──────────────────►│  WebSocket       │
│   Client    │                    │  Gateway         │
└─────────────┘                    │  (Python/Node)   │
                                   └────────┬─────────┘
                                            │
                                   ┌────────▼─────────┐
                                   │   Redis          │
                                   │   Pub/Sub        │
                                   └────────┬─────────┘
                                            │
                                   ┌────────▼─────────┐
                                   │   Symfony        │
                                   │   Application    │
                                   └──────────────────┘

Each component has clear responsibilities:

Symfony handles all business logic, authentication, authorization, database operations, and domain processing. It consumes messages from Redis via Symfony Messenger and publishes responses back. The Symfony Messenger WebSocket integration enables clean message handling.

WebSocket Gateway manages persistent connections, handles connection lifecycle events (connect, disconnect, reconnect), routes messages to Redis, and broadcasts updates to connected clients. It stays lightweight and stateless. This effectively creates a PHP WebSocket server pattern through the proxy architecture.

Redis decouples the two services, enables horizontal scaling, and handles pub/sub routing between them.

Step-by-Step Implementation

Step 1: Set Up Redis and Symfony Messenger

First, install the Redis transport for Symfony Messenger:

composer require symfony/redis-messenger

Configure your transports in config/packages/messenger.yaml. The Symfony Messenger Redis transport configuration looks like this:

framework:
    messenger:
        transports:
            websocket_inbound:
                dsn: 'redis://localhost:6379/websocket_inbound'
            websocket_outbound:
                dsn: 'redis://localhost:6379/websocket_outbound'
        
        routing:
            'App\Message\IncomingWebSocketMessage': websocket_inbound

Step 2: Create Message Classes

Define your message structure:

// src/Message/IncomingWebSocketMessage.php
namespace App\Message;

class IncomingWebSocketMessage
{
    public function __construct(
        public readonly string $userId,
        public readonly string $type,
        public readonly array $payload,
        public readonly string $connectionId,
    ) {}
}
// src/Message/OutgoingWebSocketMessage.php
namespace App\Message;

class OutgoingWebSocketMessage
{
    public function __construct(
        public readonly string $target,
        public readonly string $type,
        public readonly array $payload,
    ) {}
}

Step 3: Build the Message Handler

Create a handler that processes incoming WebSocket messages:

// src/MessageHandler/WebSocketMessageHandler.php
namespace App\MessageHandler;

use App\Message\IncomingWebSocketMessage;
use App\Message\OutgoingWebSocketMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;

#[AsMessageHandler]
class WebSocketMessageHandler
{
    public function __construct(
        private MessageBusInterface $bus,
        private ChatService $chatService,
    ) {}

    public function __invoke(IncomingWebSocketMessage $message): void
    {
        match ($message->type) {
            'chat.message' => $this->handleChatMessage($message),
            'chat.typing' => $this->handleTypingIndicator($message),
            'presence.update' => $this->handlePresenceUpdate($message),
            default => null,
        };
    }

    private function handleChatMessage(IncomingWebSocketMessage $message): void
    {
        $savedMessage = $this->chatService->saveMessage(
            $message->userId,
            $message->payload['roomId'],
            $message->payload['content']
        );

        $this->bus->dispatch(new OutgoingWebSocketMessage(
            target: 'room:' . $message->payload['roomId'],
            type: 'chat.new_message',
            payload: [
                'id' => $savedMessage->getId(),
                'content' => $savedMessage->getContent(),
                'author' => $savedMessage->getAuthor()->getUsername(),
                'timestamp' => $savedMessage->getCreatedAt()->format('c'),
            ]
        ));
    }
}

Step 4: Create the WebSocket Token Endpoint

Clients need a short-lived token to connect to the WebSocket gateway. WebSocket JWT authentication is essential for securing these connections:

// src/Controller/WebSocketController.php
namespace App\Controller;

use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

class WebSocketController extends AbstractController
{
    public function __construct(
        private JWTEncoderInterface $jwtEncoder,
    ) {}

    #[Route('/api/ws-token', methods: ['POST'])]
    #[IsGranted('ROLE_USER')]
    public function generateToken(): JsonResponse
    {
        $user = $this->getUser();

        $token = $this->jwtEncoder->encode([
            'user_id' => $user->getId(),
            'username' => $user->getUsername(),
            'exp' => time()   300,
            'scope' => 'websocket',
        ]);

        return new JsonResponse(['token' => $token]);
    }
}

Step 5: Build the Python WebSocket Gateway

Our team found that Python with FastAPI provides an excellent balance of performance and readability for WebSocket gateways. Here's a basic implementation using Python FastAPI WebSocket:

# gateway/main.py
import asyncio
import json
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import redis.asyncio as redis
import jwt

app = FastAPI()

REDIS_URL = "redis://localhost:6379"
JWT_SECRET = "your-symfony-jwt-secret"

class ConnectionManager:
    def __init__(self):
        self.active_connections: dict[str, WebSocket] = {}
        self.user_rooms: dict[str, set[str]] = {}

    async def connect(self, websocket: WebSocket, user_id: str):
        await websocket.accept()
        self.active_connections[user_id] = websocket
        self.user_rooms[user_id] = set()

    def disconnect(self, user_id: str):
        if user_id in self.active_connections:
            del self.active_connections[user_id]
        if user_id in self.user_rooms:
            del self.user_rooms[user_id]

    async def send_to_user(self, user_id: str, message: dict):
        if user_id in self.active_connections:
            await self.active_connections[user_id].send_json(message)

    async def send_to_room(self, room_id: str, message: dict):
        for user_id, rooms in self.user_rooms.items():
            if room_id in rooms:
                await self.send_to_user(user_id, message)

manager = ConnectionManager()

def verify_token(token: str) -> dict | None:
    try:
        payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
        if payload.get("scope") != "websocket":
            return None
        return payload
    except jwt.InvalidTokenError:
        return None

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, token: str):
    payload = verify_token(token)
    if not payload:
        await websocket.close(code=4001)
        return

    user_id = payload["user_id"]
    await manager.connect(websocket, user_id)

    redis_client = redis.from_url(REDIS_URL)

    try:
        while True:
            data = await websocket.receive_json()
            
            await redis_client.publish(
                "websocket_inbound",
                json.dumps({
                    "user_id": user_id,
                    "type": data.get("type"),
                    "payload": data.get("payload", {}),
                    "connection_id": str(id(websocket)),
                })
            )
    except WebSocketDisconnect:
        manager.disconnect(user_id)
    finally:
        await redis_client.close()

async def redis_listener():
    redis_client = redis.from_url(REDIS_URL)
    pubsub = redis_client.pubsub()
    await pubsub.subscribe("websocket_outbound")

    async for message in pubsub.listen():
        if message["type"] == "message":
            data = json.loads(message["data"])
            target = data.get("target", "")
            
            if target.startswith("room:"):
                room_id = target[5:]
                await manager.send_to_room(room_id, data)
            elif target.startswith("user:"):
                user_id = target[5:]
                await manager.send_to_user(user_id, data)

@app.on_event("startup")
async def startup():
    asyncio.create_task(redis_listener())

Step 6: Publish Outbound Messages from Symfony

Create a service to publish messages back to the WebSocket gateway:

// src/Service/WebSocketPublisher.php
namespace App\Service;

use App\Message\OutgoingWebSocketMessage;
use Predis\Client;

class WebSocketPublisher
{
    public function __construct(
        private Client $redis,
    ) {}

    public function publish(OutgoingWebSocketMessage $message): void
    {
        $this->redis->publish('websocket_outbound', json_encode([
            'target' => $message->target,
            'type' => $message->type,
            'payload' => $message->payload,
        ]));
    }
}

Step 7: Start Your Workers

Run the Symfony Messenger worker to process incoming messages:

php bin/console messenger:consume websocket_inbound -vv

Start the Python gateway:

uvicorn gateway.main:app --host 0.0.0.0 --port 8080

Common Mistakes to Avoid

1. Putting business logic in the gateway

The gateway should only manage connections and route messages. All validation, authorization checks, and domain logic belong in Symfony. If you start adding business rules to the gateway, you'll end up with duplicated logic and inconsistent behavior.

2. Not handling reconnection gracefully

Clients will disconnect. Networks fail, browsers refresh, laptops sleep. Design your client to automatically reconnect and re-subscribe to relevant channels. Store subscription state in Redis so clients can resume where they left off.

3. Ignoring backpressure

If Symfony can't process messages as fast as they arrive, your Redis queues will grow unbounded. Implement rate limiting at the gateway level and monitor queue depths. Consider dead letter queues for messages that repeatedly fail processing.

4. Using Pub/Sub for critical messages

Redis Pub/Sub has at-most-once delivery. If no subscriber is listening when a message arrives, it's gone. For messages that must not be lost, use Redis Streams or switch to RabbitMQ with persistent queues.

5. Validating tokens only at connection time

JWT tokens expire. If a user's session should be terminated (logout, permission change), you need a way to close their WebSocket connection. Store active connections in Redis and implement a mechanism to force-disconnect specific users.

Testing and Verification

Testing the Connection Flow

  • Start all services (Redis, Symfony, Python gateway)
  • Request a WebSocket token from /api/ws-token
  • Connect to ws://localhost:8080/ws?token=YOUR_TOKEN
  • Send a test message: {"type": "chat.message", "payload": {"roomId": "test", "content": "Hello"}}
  • Verify the message appears in Symfony logs
  • Confirm the broadcast returns through the WebSocket

Monitoring Connection Health

Add a simple health check to your gateway:

@app.get("/health")
async def health():
    return {
        "status": "healthy",
        "active_connections": len(manager.active_connections),
    }

Monitor these metrics in production:

  • Active connection count
  • Message throughput (in/out per second)
  • Redis queue depth
  • Symfony worker processing time

Choosing Between Message Brokers

We recommend starting with Redis for most applications. It's simpler to set up, has lower latency, and integrates directly with Symfony Messenger. Understanding RabbitMQ vs Redis WebSocket trade-offs helps when choosing your message broker.

Redis Pub/Sub has low setup complexity, sub-millisecond latency, no message persistence, and at-most-once delivery guarantee with no complex routing. Redis Streams has low setup complexity, sub-millisecond latency, message persistence, at-least-once delivery guarantee, and limited complex routing. RabbitMQ has medium setup complexity, 1-5ms latency, message persistence, at-least-once delivery guarantee, and full complex routing support.

Switch to RabbitMQ when you need guaranteed delivery, complex routing patterns, or your message volume exceeds what a single Redis instance can handle.

Scaling for Production

WebSocket horizontal scaling requires careful consideration of both the gateway and worker components. Here's how to achieve scalable real-time Symfony deployments.

Horizontal Scaling

Both components can scale horizontally:

WebSocket Gateway: Deploy multiple instances behind a load balancer with sticky sessions. Use the Redis adapter to share state across instances.

Symfony Workers: Run multiple messenger:consume processes using Supervisor:

[program:messenger-websocket]
command=php /var/www/bin/console messenger:consume websocket_inbound --time-limit=3600
numprocs=4
autostart=true
autorestart=true

Infrastructure Configuration

Your reverse proxy must support WebSocket upgrades. For NGINX:

location /ws {
    proxy_pass http://gateway:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_read_timeout 86400;
}

Conclusion

Building WebSocket applications in Symfony requires stepping outside the standard Mercure pattern when you need true bidirectional communication. The external gateway architecture, while more complex to set up, gives you the flexibility and scalability that real-time interactive applications demand. By keeping connection management separate from business logic, you can scale each component based on its actual needs and use the best tools for each job.

The approach outlined here handles everything from simple chat applications to high-frequency trading dashboards. Start with the basic pattern, then add complexity as your requirements grow.

If you're planning a real-time feature for your Symfony application and unsure whether Mercure will suffice or you need the full WebSocket approach, we can help you evaluate the trade-offs. Our team has implemented both patterns across different scales and can guide you toward the architecture that fits your specific requirements and team capabilities.

Share this article