DEV Community

Cover image for Master Server-Sent Events (SSE) in Symfony 7.4
Matt Mochalkin
Matt Mochalkin

Posted on

Master Server-Sent Events (SSE) in Symfony 7.4

I often see teams reach for WebSockets (via Socket.io or Pusher) when they simply need to update a UI with server-side state changes. While WebSockets are powerful, they are often overkill. If your requirement is unidirectional (Server → Client) — like stock tickers, progress bars, or notification feeds — Server-Sent Events (SSE) is the protocol you should master.

SSE runs over standard HTTP/1.1 or HTTP/2. It doesn’t require complex handshakes, works with standard authentication mechanisms and native support is built into every modern browser via the EventSource API.

In this guide, we will implement SSE in Symfony 7.4 using two approaches:

  1. The Native Approach: Using StreamedResponse (Great for understanding the protocol and simple, low-concurrency tasks).
  2. The Production Approach: Using the Mercure protocol (The scalable, non-blocking standard for Symfony).

The Native Approach (StreamedResponse)

Symfony’s StreamedResponse allows you to keep an HTTP connection open and flush data to the client incrementally. This is the “raw” implementation of the SSE standard.

Create a new controller. We will use a generator or a loop to simulate streaming data.

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/api/stream', name: 'api_stream_stock')]
class StockStreamController extends AbstractController
{
    #[Route('/stock/{symbol}', name: 'stock_ticker', methods: ['GET'])]
    public function stream(string $symbol): StreamedResponse
    {
        // 1. Create the StreamedResponse
        $response = new StreamedResponse(function () use ($symbol) {
            // 2. Prevent PHP time limits for long-running processes
            set_time_limit(0); 

            // 3. Simple loop to simulate data stream
            // In a real app, you might check Redis or a Database here
            $i = 0;
            while (true) {
                // Connection check: Stop if client disconnected
                if (connection_aborted()) {
                    break;
                }

                $data = [
                    'symbol' => strtoupper($symbol),
                    'price' => rand(100, 200),
                    'timestamp' => date('c'),
                    'sequence' => $i++
                ];

                // 4. Format according to SSE Spec
                // Format: "data: {payload}\n\n"
                echo "event: stock_update\n"; // Optional event name
                echo 'data: ' . json_encode($data) . "\n\n";

                // 5. Critical: Flush the buffer to send data immediately
                ob_flush();
                flush();

                // simulate delay
                sleep(2);
            }
        });

        // 6. Set Headers specifically for SSE
        $response->headers->set('Content-Type', 'text/event-stream');
        $response->headers->set('Cache-Control', 'no-cache');
        $response->headers->set('X-Accel-Buffering', 'no'); // Crucial for Nginx

        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

You do not need a library for this. The browser’s native EventSource is robust.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Symfony SSE Stream</title>
</head>
<body>
    <h1>Live Ticker: <span id="symbol">LOADING...</span></h1>
    <div id="price" style="font-size: 2rem;">--</div>
    <ul id="log"></ul>

    <script>
        const symbol = 'AAPL';
        // Connect to the Symfony endpoint
        const eventSource = new EventSource(`/api/stream/stock/${symbol}`);

        // Listen for the specific 'stock_update' event name defined in PHP
        eventSource.addEventListener('stock_update', (e) => {
            const data = JSON.parse(e.data);

            document.getElementById('symbol').innerText = data.symbol;
            document.getElementById('price').innerText = '$' + data.price;

            // Log for visibility
            const li = document.createElement('li');
            li.innerText = `${data.timestamp}: $${data.price}`;
            document.getElementById('log').prepend(li);
        });

        eventSource.onerror = (err) => {
            console.error("EventSource failed:", err);
            // EventSource auto-reconnects by default, but you can handle closure here
        };
    </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Server Configuration (Critical)

This is where most developers fail. Web servers (nginx etc.) love to buffer output to optimize compression. For SSE, buffering kills the stream (the client receives nothing until the buffer fills).

You must disable fastcgi_buffering or use the X-Accel-Buffering: no header we added in the controller.

# inside your location ~ \.php$ block
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# ... standard config ...
# Ensure buffering is off for SSE if headers fail
fastcgi_buffering off;
Enter fullscreen mode Exit fullscreen mode

Check your php.ini. Ensure output_buffering is set to Off or a low value.

Verification:

  1. Start your local server: symfony server:start
  2. Open your terminal.
  3. Use curl to verify the stream without a browser:
curl -N -v http://127.0.0.1:8000/api/stream/stock/AAPL
Enter fullscreen mode Exit fullscreen mode
  • -N: Disables buffering in curl.
  • Expected Output: You should see one JSON object appear every 2 seconds.

The “Senior” Reality Check (Why native is dangerous)

While the code above works, I rarely recommend it for high-traffic production apps.

PHP is synchronous. When a client connects to /api/stream/stock/AAPL, that PHP-FPM worker process enters a while(true) loop. It is locked.

If you have 50 PHP-FPM workers and 50 users open this page, your entire site goes down. No other requests can be processed.

It also keeps a database connection open if you aren’t careful.

You have to offload the open connection management to a dedicated service specifically designed for it. In the Symfony ecosystem, that solution is Mercure.

The Production Approach (Mercure)

Mercure is an open protocol for real-time updates. It acts as a Hub.

  1. Client connects to the Hub (not your PHP app) to listen for events.
  2. Symfony sends a single POST request to the Hub when data changes.
  3. Hub broadcasts the data to thousands of listeners.
  4. Result: Zero blocking PHP processes.

In the production environment, you cannot leave your stream public. You need to control who can listen to what.

Mercure uses JSON Web Tokens (JWT) for this.

  • Publisher JWT: Allows your Symfony app to push data (handled automatically by the bundle via MERCURE_JWT_SECRET).
  • Subscriber JWT: Allows a user’s browser to listen to private updates.

Here is how to implement the Subscriber JWT flow in Symfony 7.4.

Install JWT Library
We need a library to generate tokens manually for our users. lcobucci/jwt is the standard in the PHP ecosystem.

composer require lcobucci/jwt
Enter fullscreen mode Exit fullscreen mode

The Token Generator Service
Create a service that generates a signed JWT for a specific user. This token will contain a mercure claim listing the topics the user is allowed to access.

//src/Service/MercureTokenGenerator.php

namespace App\Service;

use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Hmac\Sha256;
use Lcobucci\JWT\Signer\Key\InMemory;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

class MercureTokenGenerator
{
    private Configuration $config;

    public function __construct(
        // Inject the same secret used in your .env for the Mercure Hub
        #[Autowire('%env(MERCURE_JWT_SECRET)%')]
        private string $secret
    ) {
        $this->config = Configuration::forSymmetricSigner(
            new Sha256(),
            InMemory::plainText($this->secret)
        );
    }

    public function generate(string $userTopic): string
    {
        return $this->config->builder()
            ->withClaim('mercure', [
                'subscribe' => [
                    $userTopic, // Allow access to this specific topic
                    // 'https://mysite.com/books/{id}' // You can add patterns here
                ]
            ])
            ->getToken($this->config->signer(), $this->config->signingKey())
            ->toString();
    }
}
Enter fullscreen mode Exit fullscreen mode

Injecting the Token (The Cookie Method)
The native browser EventSource API does not support sending HTTP Headers (like Authorization: Bearer).

To solve this without adding heavy JavaScript polyfills, we use a Cookie. The Mercure Hub automatically looks for a cookie named mercureAuthorization.

//src/Controller/StockPageController.php

namespace App\Controller;

use App\Service\MercureTokenGenerator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class StockPageController extends AbstractController
{
    #[Route('/dashboard', name: 'app_dashboard')]
    public function index(MercureTokenGenerator $tokenGenerator): Response
    {
        // 1. Define the private topic this user is allowed to see
        $userTopic = 'https://mysite.com/user/123/alerts';

        // 2. Generate the JWT
        $token = $tokenGenerator->generate($userTopic);

        // 3. Create the response (render your Twig template)
        $response = $this->render('dashboard/index.html.twig', [
            'userTopic' => $userTopic
        ]);

        // 4. Attach the JWT as a Cookie
        // The Hub must be on the same domain (or subdomain) for this to work
        $response->headers->setCookie(Cookie::create(
            'mercureAuthorization',
            $token,
            0,              // Session cookie
            '/.well-known/mercure', // Path (critical: restricts cookie to Hub)
            null,           // Domain (null = current domain)
            false,          // Secure (true if HTTPS)
            true,           // HttpOnly
            false,          // Raw
            'strict'        // SameSite
        ));

        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Updating the Publisher
Now, update your publisher service to mark the update as Private.

// ...
public function publishPrivateAlert(string $userId, string $message): void
{
    $topic = "https://mysite.com/user/{$userId}/alerts";

    $update = new Update(
        $topic,
        json_encode(['alert' => $message]),
        true // <--- TRUE marks this as Private
    );

    $this->hub->publish($update);
}
Enter fullscreen mode Exit fullscreen mode

Client Side (No Changes Needed)
Because we used a Cookie, the JavaScript remains exactly the same. The browser will automatically send the mercureAuthorization cookie when it connects to the Hub.

// The browser sends the cookie automatically!
const eventSource = new EventSource(hubUrl);
Enter fullscreen mode Exit fullscreen mode

Verification

  1. Clear Cookies: Clear your browser cookies for localhost.
  2. Load Dashboard: Visit /dashboard. Inspect the Network tab. You should see a Set-Cookie header with mercureAuthorization.
  3. Check Connection: The EventSource request to the Hub should now be green (200 OK).
  4. Test Privacy: Try to curl the Hub directly without the cookie; you will receive a 401 Unauthorized for private topics.

Conclusion

As we have explored, implementing real-time data in Symfony 7.4 isn’t just about opening a connection; it is about choosing the architecture that matches your traffic patterns.

While the Native StreamedResponse offers a quick, dependency-free route for administrative tasks (like export progress bars) or low-traffic internal tools, it carries significant risks regarding PHP worker exhaustion. It is a synchronous solution in an asynchronous world.

For any user-facing feature — whether it’s a live dashboard, a notification center, or a collaborative tool — Mercure is the non-negotiable standard for the modern Symfony ecosystem. It completely decouples your application logic from the connection management, allowing your Symfony backend to remain stateless and performant while Caddy handles the heavy lifting of maintaining thousands of idle connections.

Mastering these patterns allows you to move beyond simple “request-response” lifecycles and build applications that feel alive and responsive.

Let’s stay in touch

Real-time architecture often brings hidden complexities regarding load balancing, reverse proxy configuration (Nginx/HAProxy) and security boundaries.

Let’s discuss how we can make your application real-time ready.

Top comments (2)

Collapse
 
hgalt profile image
Hans-Georg Althoff

Hi Matt, sorry but I'm deep into the Nginx Functions. In my opinion, there are some information missing.
The client tried to access /api/stream/stock AAPL but there is nothing!
What I have done wrong or how I have to change the url?

UserTopic = 'myside.com/user/123/alerts';
Sure I have to replace myside.com but I don't have anything under user.
So what is missing?

You are writting update the Publisher!
Which file I have to update?

Thaks very much.

Collapse
 
mattleads profile image
Matt Mochalkin

Hi! Great questions. SSE and Mercure behave differently than standard "request-response" logic. Here is what is happening:

  1. "/api/stream/stock/AAPL" is empty If you see "nothing," it is almost buffering.

Even though the code adds the X-Accel-Buffering: no header, your specific Nginx configuration might be ignoring it or overriding it. Nginx loves to wait until it has 4kb or 8kb of data before sending it to the browser. Since SSE sends tiny bits of data, Nginx holds onto it, and the client sees "loading..." with no content. You have need to explicitly add fastcgi_buffering off; in your Nginx location ~ .php$ block.

Test with curl -N -v localhost/api/stream/stock/AAPL

  1. UserTopic and "myside.com" This is the most common confusion with Mercure. The URL myside.com/user/123/alerts is just a string identifier (an IRI) not real URL. You do not need to create a Controller or a route for it.

  2. Updating the Publisher The article assumes you have a Service that handles your business logic, but doesn't explicitly create it. You should create a new service for this.

namespace App\Service;

use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;

class AlertPublisher
{
    public function __construct(
        private HubInterface $hub
    ) {}

    public function publishPrivateAlert(string $userId, string $message): void
    {
        $topic = "https://myside.com/user/{$userId}/alerts";

        $update = new Update(
            $topic,
            json_encode(['alert' => $message]),
            true // Private
        );

        $this->hub->publish($update);
    }
}
Enter fullscreen mode Exit fullscreen mode

Then, you can inject AlertPublisher into any Controller to trigger the notification.