The blog

EasyAdmin & Mercure: a concrete use case

Published on June 13, 2023

EasyAdmin is one of the main admin generators available for Symfony applications. As it uses the standard Symfony security component for user authentication, many users can log in and modify data at the same time.

#

The problem

You are editing an entity, for instance, an article. You start to modify a given property, for instance, the quantity. Meanwhile, another administrator also edits the same entity. He modifies another property, let's say the price of the article and validates before you do. You are on the edit page and check all the product information so everything is OK.

Now, you click the save button. What happens in this case?

You don't have any errors, but you have just overridden your colleague's modifications. One solution to this problem is to implement a lock mechanism as explained in the Doctrine documentation. It requires you to add a version field on the entity you want to ensure data integrity.

Here is a code snippet extracted from the Doctrine documentation to show how it works:

<?php

use Doctrine\DBAL\LockMode;
use Doctrine\ORM\OptimisticLockException;

$theEntityId = 1;
$expectedVersion = 184;

try {
   $entity = $em->find('Article', $theEntityId, LockMode::OPTIMISTIC, $expectedVersion);
   // do the work
   $em->flush();
} catch(OptimisticLockException $e) {
   echo "Sorry, but someone else has already changed this entity. Please apply the changes again!";
}

If someone else modifies the entity, the $expectedVersion will not match and you will get an error. While this strategy resolves the value conflicts, it degrades the user experience (UX). What about notifying the user to warn them before they try to save their changes?

#

Mercure

Mercure is an open solution for real-time communications designed to be fast and reliable. It is a modern and convenient replacement for both the WebSocket API and the higher-level libraries and services relying on it.

Instead of using Doctrine’s lock feature, let’s use it to push real-time notifications to warn all the users on the same page that someone else has modified the entity and therefore that the edit form should be reloaded before submitting modifications. This is the nominal case.

When you are on a list, you can potentially view outdated data, but it's not as critical as no data will be lost in this case.

#

Demo

Administrator Slim edits the 1st article, he wants to decrease the quantity, from 10 to 9:

At the same time, administrator Loïc edits the same article, he wants to increase the price to 51:

Administrator Slim validates its modifications with one of the save buttons.

Administrator Loïc receives a real-time notification, the modification is handled by JavaScript to warn the user that the article was modified by someone else. He is invited to reload the page before doing its own modifications:

Administrator Loïc refreshes the page, the price has been correctly updated.

Administrator Loïc decreases the quantity to 9 and saves.

The final state of the article is:

  • Quantity: 9
  • Price: 51

Without the real-time notification, the state would have been :

  • Quantity: 10
  • Price: 51

And Slim’s modification would have been lost.

#

How does it work?

This feature is opt-in, which means it will be activated only if you want to. The first step is to install the Mercure bundle:

composer require mercure-bundle

This bundle includes a recipe for Docker to add a mercure container. You may have to modify the Mercure parameters if you don't use the Docker Mercure recipe.

But it works with the default settings, and you should see the notifications without extra configuration. Of course, you have to refresh the Docker containers for your project, so Mercure gets started.

docker compose up –wait

You should now see the Mercure container.

docker ps

CONTAINER ID   IMAGE         	COMMAND              	CREATED     	STATUS     	PORTS                                  	NAMES
cb37e7a21a64   dunglas/mercure   "/usr/bin/caddy run …"   8 seconds ago   Up 7 seconds   443/tcp, 2019/tcp, 0.0.0.0:50943->80/tcp   easyadmin-mercure-demo-mercure-1

In the following output, we can see that the exposed port is 50943. You can also use the docker port command to check the port that is exposed locally:

docker port cb37e7a21a64
80/tcp -> 0.0.0.0:50943

If you use the Symfony CLI, the Mercure container is automatically detected and it exposes the MERCURE_PUBLIC_URL and MERCURE_URL environment variables (it only works with the dunglas/mercure Docker image). (documentation)

#

Implementation

Technically, when Mercure is enabled, a data-ea-mercure-url HTML attribute is generated, it contains the topic the user must subscribe to receive notifications: 

data-ea-mercure-url="{{ ea_call_function_if_exists('mercure', mercure_topic, {'hub' : hub_name}) }}"

Then we subscribe to this Mercure topic thanks to the EventSource object:

const mercureURL = document.body.getAttribute('data-ea-mercure-url');
if (! mercureURL) {
   return;
}


const eventSource = new EventSource(mercureURL);

When a notification is sent the concerned entities’ ids are extracted and the HTML is modified accordingly to notify the user:

eventSource.onmessage = event => {
   const data = JSON.parse(event.data);
   const action = data.action;
   const id = Object.values(data.id)[0];
   const bodyId = document.body.getAttribute('id');
   const row = document.querySelector('tr[data-id="'+id+'"]');

Check out the full code here

#

Demo application

A small demo application has been created so you can easily test the feature. Here it is: https://github.com/coopTilleuls/easyadmin-mercure-demo.

A README is explaining how to run the application with Docker and the Symfony CLI. You can go back to the standard behavior by removing mercure-bundle:

composer remove mercure-bundle
   docker compose down --remove-orphans
   docker compose up --wait

Head back to the EasyAdmin interface, you shouldn't have the notifications anymore, and no error logs will show up on the server and client side. Don’t forget to refresh your browser!

#

Testing with Panther

This feature is a good use case for Mercure, but it is also a good use case for Panther. Indeed, we can’t test this with standard Symfony functional tests (extending the WebTestCase). Instead, we can use Panther which handles JavaScript as it uses a real headless browser (Chrome or Firefox). It’s even harder in this case because we must use two totally isolated browser instances (administrator 1 and administrator 2).

It provides a specific Symfony\Component\Panther\PantherTestCase test case which adds several useful methods and asserts. Let’s see the code we can write to test the exact same scenario we viewed before:

<?php


declare(strict_types=1);


namespace App\Tests\E2E\Controller\Admin;


use Facebook\WebDriver\Exception\NoSuchElementException;
use Facebook\WebDriver\Exception\TimeoutException;
use Symfony\Component\Panther\PantherTestCase as E2ETestCase;


/**
* @see https://github.com/symfony/panther#creating-isolated-browsers-to-test-apps-using-mercure-or-websockets
*/
final class ArticleCrudControllerTest extends E2ETestCase
{
   private const SYMFONY_SERVER_URL = 'http://127.0.0.1:8000'; // Use the Symfony CLI local web server


   private const ARTICLE_LIST_URL = '/admin?crudAction=index&crudControllerFqcn=App\Controller\Admin\ArticleCrudController';


   // this is the second article as we created the first one in the AdminCrud test
   private const ARTICLE_EDIT_URL = '/admin?crudAction=edit&crudControllerFqcn=App\Controller\Admin\ArticleCrudController&entityId=2';


   private const ARTICLE_NEW_URL = '/admin?crudAction=new&crudControllerFqcn=App\Controller\Admin\ArticleCrudController';


   private const NOTIFICATION_SELECTOR = '#conflict_notification';


   /**
    * @throws NoSuchElementException
    * @throws TimeoutException
    */
   public function testMercureNotification(): void
   {
       $this->takeScreenshotIfTestFailed();


       // 1st administrator connects
       $client = self::createPantherClient([
           'external_base_uri' => self::SYMFONY_SERVER_URL,
       ]);


       $client->request('GET', self::ARTICLE_LIST_URL);
       self::assertSelectorTextContains('body', 'Article');
       self::assertSelectorTextContains('body', 'Add Article');


       // 1st administrator creates an article
       $client->request('GET', self::ARTICLE_NEW_URL);
       $client->submitForm('Create', [
           'Article[code]' => 'CDB142',
           'Article[description]' => 'Chaise de bureau 2',
           'Article[quantity]' => '10',
           'Article[price]' => '50',
       ]);


       // 1st admin access the edit page of the article he just created
       $client->request('GET', self::ARTICLE_EDIT_URL);


       self::assertSelectorTextContains('body', 'Save changes');


       // 2nd administrator access the edit page of the same article and modifies the quantity
       $client2 = self::createAdditionalPantherClient();
       $client2->request('GET', self::ARTICLE_EDIT_URL);
       $client2->submitForm('Save changes', [
           'Article[quantity]' => '9',
       ]);


       // 1st admin has a notification thanks to Mercure and is invited to reload the page
       $client->waitForVisibility(self::NOTIFICATION_SELECTOR);


       self::assertSelectorIsVisible(self::NOTIFICATION_SELECTOR);
       self::assertSelectorTextContains(self::NOTIFICATION_SELECTOR, 'The data displayed is outdated');
       self::assertSelectorTextContains(self::NOTIFICATION_SELECTOR, 'Reload');
   }
}

The interesting part is here:

$client2 = self::createAdditionalPantherClient();

It allows the use of a second test client totally isolated from the first one. It helps us to trigger the Mercure update that the first client will receive. In this case, we edit a specific entity, and after, we check that the Mercure update was correctly received by the first client: the notification is displayed with the reload button.

#

Notifications from other sources

Your entities can of course be modified by other sources than EasyAdmin.

In this case, you can emit the notifications manually, for example in a domain listener. The code would look like this, given $article is the Doctrine entity you receive inside the listener:

// use Symfony\Component\Mercure\Update;
// use EasyCorp\Bundle\EasyAdminBundle\Router\AdminUrlGenerator;
// use Symfony\Component\Mercure\HubInterface;
$topic = $this->adminUrlGenerator->setController(Article::class)
   ->unsetAllExcept('crudControllerFqcn')->generateUrl();
$update = new Update(
   $topic,
   (string) json_encode(['id' => 1]),
);


$this->hub->publish($update);
#

Conclusion

This was a concrete use case showing how Mercure can help to improve the user experience. 

Of course, using a Doctrine lock would be more robust. We would recommend consolidating your data state by using both. The pros of Mercure are that the database doesn't have to be modified and you avoid Doctrine lock errors (and user frustration) as the entity would be refreshed thanks to the notifications.

Do you have other Mercure use cases in mind? Don't hesitate to publish a blog post about it!

If you like this feature, don't hesitate to add a 👍 or a comment on the ticket/RFC.

The blog

Go further