Stefan Pöltl
Jul 18 · 8 min read

You are searching for a handy way to build instant notifications paired with a great programming Framework? You don’t like Javascript on the serverside and difficult Websockets? You dream about a realtime application like a Bitcoin trading platform? With Symfony and Mercure you’re well prepared and I will show you how to take off.

What is Mercure?

Mercure is a notification hub where you fire HTTP POST requests to and your payload is getting delivered to all connected clients instantly. Every HTTP based Client is able to get notifications pushed in real time. But how does this work?

The mercure hub is implemented in Golang and uses Server Sent Events to transmit data to subscribed clients like this:

  • A client subscribes via HTTP to the server and the server opens a connection in read only mode for the client(unidirectional connection).
  • All the time HTTP requests are used for the persistent connection and via HTTP2 it’s possible to get multiplexing out of the box(binary framing layer in HTTP2).

The mercure server sends a message with the following headers to a client(Golang code from the mercure hub repo):

// Keep alive, useful only for HTTP 1 clients w.Header().Set("Connection", "keep-alive")// Server-sent events
w.Header().Set("Content-Type", "text/event-stream")
// Disable cache, even for old browsers
proxiesw.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expire", "0") // NGINX support
w.Header().Set("X-Accel-Buffering", "no")
// Flush the headers
fmt.Fprint(w, ":\n")
w.(http.Flusher).Flush()

Note that even HTTP1 is supported with the keep alive option.

SSE vs Ajax Polling vs Websockets

  • Ajax polling from the client side means open a new connection and ask the server for updates within a specified time interval. The overhead of opening and closing a connection for every poll is heavy(better with HTTP2) and the connection keeps open until the server responds(connection timeouts).
  • Websockets provide a full duplex communication between the client and server but you might run into issues with proxies that can just kill the open connection. One other issue is the approach to keep connections open which was not designed for PHP(Share nothing architecture).

Benefits of Mercure

For PHP applications mercure is awesome. Used to run as a share nothing/fire and forget architecture you can fire requests to the mercure hub during the basic PHP request run. The hub scales out with go routines and pushes instant events to subscribed clients. What else is simplified or included with the mercure hub:

  • JWT support for security
  • Message encryption
  • CORS management
  • Open Source technology
  • Run everywhere (compiled Go binary)
  • Topic based push and subscription
  • History management

Setup a Symfony Project

I’m a big fan of dockerized development, because you don’t need anything else on your machine except Docker. You can find the whole project on Github:

So let’s setup Symfony with composer via Docker:

docker run --rm -it -v $PWD:/app composer create-project symfony/website-skeleton symfony_mercure# Remove Doctrine if you don't want to deal with a DB for now
docker run --rm -it -v $PWD:/app composer remove doctrine
# Add the mercure bundle
docker run --rm -it -v $PWD:/app composer require mercure

After setting up the basic project, some env variables are needed and a docker-compose.yml to run the project. The docker-compose.yml looks like this:

version: '3.4'

services:
php:
build: ./docker
env_file:
- ./.env
volumes:
- ./:/var/www/app:rw,cached
# If you develop on Linux, uncomment the following line to use a bind-mounted host directory instead
# - ./:/var/www/app:rw
working_dir: /var/www/app

nginx:
image: nginx:1.17-alpine
depends_on:
- php
volumes:
- ./public:/var/www/app/public:ro
- ./docker/vhost.conf:/etc/nginx/conf.d/default.conf
ports:
- "8080:80"

mercure:
image: dunglas/mercure
environment:
- JWT_KEY=myJWTKey
- DEMO=1
- ALLOW_ANONYMOUS=1
- PUBLISH_ALLOWED_ORIGINS=*
- CORS_ALLOWED_ORIGINS=*
- DEBUG=1
ports:
- "9090:80"

When we required the mercure bundle, already two ENV variables got added to the Symfony configurations file(config/services.yaml):

parameters: 
env(MERCURE_PUBLISH_URL): ""
env(MERCURE_JWT_SECRET): ""

The first MERCURE_PUBLISH_URL variable defines how the mercure hub can be reached from the Symfony app. The second one defines a JWT signed with the secret key set to the Hub in the docker-compose.yml: myJWTKey

So the variables in your .env file should look like this:

MERCURE_PUBLISH_URL=http://mercure/hub
MERCURE_JWT_SECRET=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6W10sInB1Ymxpc2giOlsiKiJdfX0.iTVjHoLv9bB-O5RNnTtzOFxIW-YECk2JXZeMekZ4GwA

You can create a token at jwt.io and sign it with the key. The website also gives you the possibility to define that you can publish to all topics:

jwt.io Encode/Decode UI

On the right side at the bottom you can fill in your secret key and get the token.

Subscribing with Symfony

Next step is to create a Controller to render the view for a Subscriber:

php bin/console make:controller Client

A controller and a view got added automatically. To subscribe to our mercure hub we need to add the following in the view(templates/client/index.html.twig):

{% block javascripts %}
<script>
let ul = null;
const es = new EventSource('http://localhost:9090/hub?topic=1e9');
es.onmessage = e => {
const data = JSON.parse(e.data);

if (!ul) {
ul = document.createElement('ul');

const messages = document.getElementById('main--container');
messages.innerHTML = '';
messages.append(ul);
}

const li = document.createElement('li');
li.append(document.createTextNode(data.headline));
ul.append(li);
}
</script>{% endblock %}

With the EventSource Class we subscribe to our mercure host from the client site. The ports got mapped in the docker-compose.yml to port 9090. If a message gets pushed to the Client we add a new list item to the DOM. So let’s start up docker: docker-compose up and check if pushes are coming…

Posting a message to the mercure hub

Posting with a signed JWT works like this:

curl --request POST \
--url http://localhost:9090/hub \
--header 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6W10sInB1Ymxpc2giOlsiKiJdfX0.iTVjHoLv9bB-O5RNnTtzOFxIW-YECk2JXZeMekZ4GwA' \
--header 'content-type: application/x-www-form-urlencoded' \
--data topic=1e9 \
--data 'data={
"headline": "My post to the mercure hub"
}'

Publishing with Symfony

How do we publish a message to the hub without using curl? We use Symfony! The mercure bundle automatically adds a Publisher to your container: php bin/console debug:container Publisher shows the registered component:

Detect mercure Publisher service

Let’s add a controller to publish messages to the hub with:

php bin/console make:controller Publisher

Now in the PublisherController we get the Publisher Service automatically injected via autowiring and we push whatever we want to the query param based topic:

use Symfony\Component\Mercure\Publisher;
use Symfony\Component\Mercure\Update;
class PublisherController extends AbstractController
{
/**
*
@Route("/publish/{topic}", name="publisher", methods={"POST"})
*/
public function index(Publisher $publisher, $topic, Request $request)
{
$publisher(new Update($topic, $request->getContent()));
return new Response('success');
}
}

To push a message to this route you can use curl again:

curl -X POST localhost:8080/publish/1e9 --data '{"headline":"value"}'

Chat example

After checking how the hub works with Symfony, I will show how easy it is to code a simple chat. For this the JS based client needs to be able to push and subscribe to the mercure hub. To achieve this we will reuse our /publish/{topic} route for pushing and keep the simple subscriber Javascript code from above.

First of all we create a chat controller:

php bin/console make:controller Chat

Then we need to pass the /publish/{topic} path and the subscriber topic to the view like this:

class ChatController extends AbstractController
{
/**
* @Route("/chat", name="chat")
*/
public function index()
{
return $this->render('chat/index.html.twig', [
'config' => [
'topic' => 'chat',
'publishRoute' => $this->generateUrl('publisher', ['topic' => 'chat'])
]
]);
}
}

The view part looks like this(templates/chat/index.html.twig):

{% block body %}
<h1>Chat example</h1>

<form>
<input name="username" placeholder="username" size="10">
<input name="message" placeholder="Your message..." size="50" autocomplete="off">
<input type="submit" value="Post">
</form>
<hr>

<div id="messages">
No messages
</div>
{% endblock %}
<script type="application/json" id="config">
{{ config|json_encode()|raw }}
</script>
<script>
const {topic, publishRoute} = JSON.parse(document.getElementById('config').textContent);

const subscribeURL = new URL('http://localhost:9090/hub');
subscribeURL.searchParams.append('topic', topic);

const es = new EventSource(subscribeURL);
let ul = null;
es.onmessage = ({data}) => {
const {username, message} = JSON.parse(data)
if (!username || !message) throw new Error('Invalid payload')

if (!ul) {
ul = document.createElement('ul');

const messages = document.getElementById('messages');
messages.innerHTML = '';
messages.append(ul);
}

const li = document.createElement('li')
li.append(document.createTextNode(`<${username}> ${message}`))
ul.append(li)
};

document.querySelector('form').onsubmit = function (e) {
e.preventDefault();

fetch(publishRoute, {method: 'POST', body: JSON.stringify({username: this.elements.username.value, message: this.elements.message.value})});
this.elements.message.value = '';
this.elements.message.focus();
}
</script>
{% endblock %}

First part of the view is the HTML markup to send a message and a empty container to render the incoming messages. Then we render the configuration passed from the chat controller as json string. In the Javascript logic we parse this configuration and subscribe to the mercure hub for incoming messages with a topic given from the config. At the end we register an event on the form submit to send a POST request with the fetch API to our /publish/{topic} route. The working example looks like this:

Chat messaging with the mercure hub

Last-Event-ID

With a simple modification of the client code we can add the possibility to read missed messages after being offline for a while. Whenever you reconnect and send the Last-Event-ID param with the connection string, you get all missed messages since the last time you got a message and saved the corresponding event id.

let ul = null;
const hubUrl = new URL('http://localhost:9090/hub');
hubUrl.searchParams.append('topic', '1e9');

const lastEventId = localStorage.getItem('lastEventId');
if (lastEventId != null){
hubUrl.searchParams.append('Last-Event-ID', lastEventId);
}


const es = new EventSource(hubUrl);
es.onmessage = e => {
localStorage.setItem('lastEventId', e.lastEventId);
const data = JSON.parse(e.data);

if (!ul) {
ul = document.createElement('ul');

const messages = document.getElementById('main--container');
messages.innerHTML = '';
messages.append(ul);
}

const li = document.createElement('li');
li.append(document.createTextNode(data.headline));
ul.append(li);
}

As you can see we utilize the localStorage to persist the lastEventId for the case of being disconnected. You can test it with closing your Browser-Tab, send multiple messages and reopen the Tab.

You can configure the history behavior of the hub with some environment variables. HISTORY_SIZE for the number of messages kept in the history and HISTORY_CLEANUP_FREQUENCY to manage when the history should be cleaned (0=never, 1=after every message, 0.3=default).

Summary

With the mercure hub you get a programming language agnostic server to push messages to all clients that support HTTP. Simple to setup, manage and with the great integration in our loved PHP framework Symfony, you can go crazy and implement all kind of Push notification applications you ever dreamed off without any hazzle.

The Startup

Medium's largest active publication, followed by +489K people. Follow to join our community.

Stefan Pöltl

Written by

The Startup

Medium's largest active publication, followed by +489K people. Follow to join our community.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade