Add Realtime Push Capabilities in Symfony With Mercure

September 07, 2023
Written by
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
Twilion

A testament to the complexity of modern web applications is the sheer volume of data generated per second. This has made it inefficient for the client to simply poll the server, in hopes of receiving an update. Now, the server should be able to send updates to the client at will, just as the client can to the server.

In this series, I will show you how you can use the Mercure protocol in your Symfony applications to broadcast updates to the frontend. The Symfony documentation describes  Mercure as:

> An open protocol designed from the ground up to publish updates from server to clients. It is a modern and efficient alternative to timer-based polling and to WebSocket. 

The first part of this series will show you how to set up a Mercure Hub for a Symfony application. A hub is a dedicated server that handles persistent SSE (Server-Sent Events) connections with the clients. The Symfony app publishes the updates to the hub which in turn broadcasts them to clients.

You can set up a hub in two ways, either through Docker integration or by using the standalone binary. While I will show you how to do both approaches, you’re free to choose one path for the duration of the series, as it is a matter of personal preference and application stack. In addition to setting up a hub, I will show you how to dispatch and subscribe to hub updates.

Prerequisites

To follow this tutorial, you will need the following things:

Getting started

Create a new project and change into the new project directory, using the following commands.

symfony new mercure_demo
cd mercure_demo

Next, add the project dependencies using the following commands.

composer require twig mercure
composer require --dev maker profiler

Here’s what each package does:

  1. Maker: This will help us with creating controllers, entities and the like.
  2. Mercure: This bundle is provided by Symfony to simplify the process of pushing updates. It also provides a Twig service for subscribing to and receiving updates.
  3. Profiler: This bundle provides helpful debug information on different aspects of your application, including Mercure.
  4. Twig: Twig will be used to render our HTML templates

While the Mercure bundle is installing, you will see the following prompt

    The recipe for this package contains some Docker configuration.

    This may create/update docker-compose.yml or update Dockerfile (if it exists).

    Do you want to include Docker configuration from recipes?
    [y] Yes
    [n] No
    [p] Yes permanently, never ask again for this project
    [x] No permanently, never ask again for this project
    (defaults to y): 

If you want to use the Docker integration for the Mercure Hub, respond with y. If not, enter n.

Next, update the environment variables for Mercure. Start by creating a .env.local file from the .env file, which Symfony generated during creation of the project, by running the command below.

cp .env .env.local

Then, update the values in .env.local as shown below.

MERCURE_URL=http://127.0.0.1:3000/.well-known/mercure
MERCURE_PUBLIC_URL=http://127.0.0.1:3000/.well-known/mercure
MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!"

For this tutorial, the Mercure Hub will be served on port 3000.

Configuring Docker integration for Mercure Hub

If you opted to allow the Mercure recipe to set up the requisite Docker files, you will have a file named docker-compose.yml located at the root of your project folder. Update the code in the file to match the following.

version: '3'

services:
  mercure:
    image: dunglas/mercure
    restart: unless-stopped
    ports:
      - "3000:80"
    environment:
      SERVER_NAME: ':80'
      MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
      MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
      MERCURE_EXTRA_DIRECTIVES: |
        cors_origins *
        anonymous
    command: /usr/bin/caddy run --config /etc/caddy/Caddyfile.dev
    volumes:
      - mercure_data:/data
      - mercure_config:/config

volumes:
  mercure_data:
  mercure_config:

Make sure that the value for MERCURE_PUBLISHER_JWT_KEY matches the value specified in your .env.local file.

This is similar to the default configuration, save for two changes:

  1. Port 3000 on your development machine is bound to port 80 on the container, the default port for the Mercure server.
  2. The modification of the MERCURE_EXTRA_DIRECTIVES configuration to allow anonymous connections as well as requests from any origin.

Start your Mercure server using the following command.

docker compose up

Your Mercure Hub will be running on port 3000.

Configuring the Mercure Hub as a static binary

To configure the Mercure Hub as a static binary, download the archive corresponding to your operating system and architecture and extract it.

Make sure that the downloaded archive does not have legacy in its name.

Once downloaded, look for an executable file named mercure and move it to the bin folder in your project.

macOS users must download the Darwin binary, then run xattr -d com.apple.quarantine ./mercure to release the hub from quarantine.

The binary you downloaded is a custom build of the Caddy web server which includes the Mercure.rocks module. The easiest way to configure it is via a Caddyfile. At the root of your project folder, create a new file named Caddyfile.dev and add the following code to it.

:3000

log

route {
    redir / /.well-known/mercure/ui/

    mercure {
        publisher_jwt !ChangeThisMercureHubJWTSecretKey!
        subscriber_jwt !ChangeThisMercureHubJWTSecretKey!
        cors_origins *
        publish_origins *
        demo
        anonymous
        subscriptions
    }

    respond "Not Found" 404
}

The first thing you specified in the Caddyfile configuration is the port. By specifying :3000, the server will be available at port 3000. The log directive enables Caddy’s logging facilities.

Next, you have your route configuration. The redir directive reroutes all requests to the index route to /.well-known/mercure/ui/. Following that, you have your Mercure configuration which specifies the publisher_jwt, subscriber_jwt and so on. Additionally, anonymous requests (as well as requests from all origins) are permitted.

Finally, for any request to a route that does not satisfy the previously specified conditions, a 404 NOT FOUND response is returned.

Start your Mercure server using the following command.

bin/mercure run --config Caddyfile.dev

Create a controller

To test the newly set up hub, create a new controller named IndexController. This controller will have two endpoints. One will render the index page, while the other will publish an update to the Mercure Hub.

In a new terminal window/session, create the new controller with the following command.

symfony console make:controller Index

Open the newly created file (src/Controller/IndexController.php) and update it to match the following.

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Annotation\Route;

class IndexController extends AbstractController {

    #[Route('/', name: 'app_index')]
    public function index() : Response 
    {
        return $this->render('index/index.html.twig', [
            'controller_name' => 'IndexController',
        ]);
    }

    #[Route('/publish', name: 'publish')]
    public function publish(HubInterface $hub) : JsonResponse 
    {
        $update = new Update(
            '/test',
            json_encode(['update' => 'New update received at '.date("h:i:sa")])
        );

        $hub->publish($update);

        return $this->json(['message' => 'Update published']);
    }
}

The index() function is responsible for rendering the index page of the application. The publish() function is responsible for publishing updates to the Mercure Hub. The Mercure component provides a publisher service which is injected into the function definition via autowiring.

Next, an Update() object is created and passed to the publisher service. This object takes two parameters — the topic (the unit to which one can subscribe to changes), and the data for the update. Finally a JSON response is returned.

Subscribe to updates from the Twig template

To get things started, an additional script will be added to the Twig template responsible for rendering the index page. This script will add an event source to the topic you specified in the publish() function. When it receives an update, it will log them to the browser's console.

Open templates/index/index.html.twig and add the following at the bottom of the file.

{% block javascripts %}
    <script>
        const eventSource = new EventSource("{{ mercure('/test')|escape('js') }}");
        eventSource.onmessage = event => {
            console.log(JSON.parse(event.data));
        }
    </script>
{% endblock %}

Running the application

At this point, you will be able to test your setup to ensure that it is working. If you stopped your Mercure Hub, you can start it again using the appropriate command below.

# For the docker setup
docker compose up

# For the static binary setup
bin/mercure run --config Caddyfile.dev

Next, start your Symfony application. If you use the Symfony Local Web Server (which this article recommends), you must start it with the --no-tls option, using the command below.

symfony serve --no-tls

By default, this application will be served on port 8000.

Next, open two new tabs in your browser. In the first tab, open the index page and pull up the Web Console. In the second tab, navigate to the publish endpoint and reload the page a few times. Then, switch back to the first tab and notice the updates written to the Web Console.

The Symfony application is running, on the left hand side, with the Web Console open at the bottom. The Mercure Hub server is running on the right hand side. As requests are made to the Mercure app, new messages are written to the Web Console in the Symfony application window.

That's how to add realtime push capabilities in Symfony with Mercure

There you have it. While it may not look like much in terms of the final product, you have taken a massive step towards implementing real time synchronisation between the backend and frontend of your application. In case you get stuck at any point, feel free to access the codebase here. Until next time ✌🏾

Joseph Udonsak is a software engineer with a passion for solving challenges – be it building applications, or conquering new frontiers on Candy Crush. When he’s not staring at his screens, he enjoys a cold beer and laughs with his family and friends. Find him at LinkedIn, Medium, and Dev.to.