Symfony Mercure et FrankenPHP : construire un chat en temps réel avec SSE sans WebSocket

Apprenez à construire un chat en temps réel avec Symfony, Mercure et FrankenPHP. Utilisez le protocole SSE pour une expérience fluide, sans WebSocket ni JavaScript complexe.

Introduction

Lors de notre précédente expérimentation, une question simple m’a obsédé : comment construire une “classroom” en temps réel avec Symfony, sans devoir recharger la page pour savoir quels élèves sont connectés ? La réponse s’est imposée d’elle-même : Mercure. Ce protocole de Server-Sent Events (SSE) est taillé pour la mise à jour instantanée, et, combiné à Symfony UX Turbo, il forme un duo redoutable.

Aujourd’hui, on passe à la version 2 du projet. L’idée ? Rajouter un chat en temps réel, toujours en s’appuyant sur Turbo et les outils de l’écosystème Symfony. J’avais déjà bricolé un POC dans un coin, mais cette fois, on va pousser le concept plus loin : un vrai module de messagerie instantanée, fluide, réactif et élégant.

Et puisqu’on aime faire les choses bien, on intègre aussi FrankenPHP dans la stack. L’objectif : une configuration unifiée et moderne, où Mercure est directement géré par le serveur Caddy embarqué dans FrankenPHP. Résultat : moins de dépendances, moins de conteneurs, et une stack plus légère.
En gardant notre configuration précédente, on a même pu supprimer Mercure du compose.yaml, sans perdre une seule fonctionnalité.

SSE, c’est quoi au juste ?

Avant de plonger dans le code, prenons une minute pour rafraîchir les bases.
Le SSE, pour Server-Sent Events, est un protocole tout simple qui permet à un serveur d’envoyer des mises à jour en temps réel à un ou plusieurs clients, sans que ceux-ci aient à recharger la page.

Concrètement, au lieu que ton navigateur interroge sans cesse le serveur (“t’as du nouveau ?”), le serveur garde la connexion ouverte et pousse les infos dès qu’elles arrivent. C’est du push natif via HTTP, sans WebSocket ni complexité réseau.

Ce mécanisme, géré via le type MIME text/event-stream, est nativement supporté par les navigateurs modernes(pas besoin de bibliothèque JS exotique).

Dans notre cas, c’est parfait pour :

  • afficher en temps réel quels élèves sont connectés à la classroom,
  • pousser des messages de chat instantanément,
  • ou même notifier un changement de note, un nouveau devoir, etc.

Et le meilleur dans tout ça : le SSE ne se limite pas au web.
Parce que la communication repose sur HTTP, tu pourras très bien réutiliser la même configuration pour une future application mobile, un client desktop ou un service tiers, sans rien changer côté serveur.

C’est justement là que Mercure entre en scène : il agit comme hub SSE, orchestrant la distribution de ces événements à travers tes différentes apps — web, mobile ou API.
Et dans cette V2, grâce à FrankenPHP, ce hub est directement intégré, ce qui rend l’ensemble encore plus fluide.

La logique du POC

Maintenant que notre Classroom Symfony dispose déjà de ses fondations solides — affichage en temps réel, gestion des utilisateurs connectés et persistance en base de données —, la suite logique, c’est évidemment le chat.

Mais pas n’importe quel chat.
Ici, on ne parle pas d’une simple conversation de groupe façon messagerie basique. L’idée, c’est de reproduire une vraie classe virtuelle, avec une hiérarchie implicite : le professeur reste prioritaire. Ses messages doivent être plus visibles, mis en avant, un peu comme dans une discussion où la voix du prof “porte” plus fort que celle des élèves.

Ce POC (Proof of Concept) explore donc une structure conversationnelle enrichie :

  • chaque message affiche la date et l’heure de publication,
  • le nom et le statut (professeur ou élève) de l’expéditeur,
  • une mise en avant automatique des messages écrits par le professeur,
  • et tout cela actualisé en temps réel, sans rechargement de page.

C’est là que Symfony UX Turbo et FrankenPHP font la différence. Turbo gère la réactivité du front sans JavaScript lourd, tandis que FrankenPHP orchestre les flux SSE en arrière-plan, garantissant que chaque événement de chat soit diffusé instantanément à tous les participants connectés.

Ce modèle se rapproche donc plus d’un outil d’enseignement interactif que d’un chat classique : la salle de classe devient un espace vivant, où les messages circulent naturellement, hiérarchisés et contextualisés.

L’attribut #[Broadcast] : la petite magie de Symfony 7

Voici le cœur du dispositif : notre entité Message, enrichie d’un simple attribut qui change tout.

Depuis Symfony 7, il est possible de rendre n’importe quelle entité Doctrine “diffusable” en temps réel grâce à l’attribut #[Broadcast]. Cet attribut agit comme une passerelle automatique entre DoctrineMercure et Turbo.

Dans notre cas, l’entité Message est annotée avec :

#[Broadcast(topics: ['chat'])]

Concrètement, à chaque fois qu’un nouveau message est créé, mis à jour ou supprimé, Symfony publie automatiquement un événement Mercure sur le topic chat.
Le front-end, de son côté, est simplement abonné à ce topic. Résultat : chaque nouvelle publication déclenche instantanément une mise à jour visuelle du chat — sans le moindre appel AJAX ni fetch() manuel.

C’est littéralement du temps réel plug-and-play :

  • le back envoie les données,
  • Mercure diffuse via SSE,
  • Turbo Stream met à jour la page.

Et tout cela sans écrire une seule ligne de JavaScript personnalisé.

L’avantage de cette approche, c’est qu’elle délie complètement la logique applicative du mécanisme temps réel.
Le développeur se concentre sur le métier (ici : créer un message, l’associer à un utilisateur), tandis que le framework orchestre la diffusion automatique des événements sur le réseau.

En clair : le topic chat devient le canal de la salle de classe, et chaque submit du formulaire de chat déclenche une diffusion transparente sur ce flux. Les messages s’affichent pour tous les élèves connectés, dans un rendu fluide et synchronisé.

C’est une magie élégante — la magie du protocole SSE mise à la portée de tous par Symfony.

Adieu Apache, vive le roi : FrankenPHP entre en scène

Pour cette partie 2 du POC, un petit détail change tout :
on dit adieu à Apache — et on passe à FrankenPHP, le serveur PHP moderne qui intègre directement Mercure et Caddy.

Ce changement peut sembler anodin, mais en réalité, c’est un bond technologique majeur.
👉 Plus besoin d’un conteneur dédié à Mercure, ni de configurations réseau complexes entre services : tout tourne dans un seul binaire, ultra-performant et simplifié.

Moins de dépendances, plus de magie

En adoptant FrankenPHP, on fait le grand ménage :

  • suppression du container Mercure du compose.yaml,
  • simplification du réseau interne,
  • réduction des dépendances externes,
  • et surtout… aucune modification du code source Symfony.

Tout continue de fonctionner exactement comme avant, grâce à l’intégration native de Mercure dans FrankenPHP.
Tu relances le projet, et pouf, ton chat temps réel et ta liste d’élèves connectés se mettent à jour comme par magie.

Double canal de communication

Sous le capot, deux flux Mercure coexistent désormais harmonieusement :

  1. Un channel dédié au “live”, pour diffuser les mises à jour globales (connexion/déconnexion, notifications, etc.).
  2. Un channel automatique, géré par la magie du #[Broadcast] de Symfony 7, pour tout ce qui concerne les entités “diffusables” — ici, les messages de chat.

Résultat :

  • Le canal live alimente la salle de classe,
  • tandis que le broadcast Symfony s’occupe des échanges pédagogiques, sans qu’on ait besoin d’ajouter une seule ligne de code supplémentaire.

C’est la puissance combinée de Symfony, Mercure et FrankenPHP : une architecture épurée, moderne et surtout… incroyablement fluide.


Une route presque trop simple… et pourtant

C’est souvent dans la simplicité que réside la vraie magie.
Et cette route en est l’exemple parfait.
À première vue, rien de spectaculaire : aucune requête AJAX, pas de WebSocket, pas même de logique de “polling”.
Et pourtant… tout fonctionne en temps réel.

C’est là que Symfony UX Turbo entre en scène : il gère en toute transparence la réactivité de l’interface.
Résultat : une salle de classe numérique fluide, sans qu’on ait besoin de se battre avec du JavaScript personnalisé.

La route show() : cœur du chat temps réel

Voici le code complet de la méthode — et si tu ne l’avais jamais vu, tu pourrais croire qu’il s’agit d’un simple contrôleur d’affichage :

#[Route('/{id}', name: 'app_classroom_show', methods: ['GET'])]
public function show(ClassroomSession $session): Response
{
    $user = $this->getUser();

    // #[Route('/{id}', name: 'app_classroom_show', methods: ['GET'])]
public function show(ClassroomSession $session): Response
{
    $user = $this->getUser();

    // Vérifier l'accès : enseignant OU étudiant inscrit OU session ouverte
    $hasAccess = $session->getTeacher() === $user ||
                $session->getStudents()->contains($user) ||
                ($session->isOpen() && $session->isActive());

    if (!$hasAccess) {
        throw $this->createAccessDeniedException('You don\'t have access to this session');
    }

    // Marquer l'utilisateur comme en ligne
    $this->presenceService->markUserOnline($user, $session);

    // Récupérer la liste des utilisateurs en ligne
    $onlineUsers = $this->presenceService->getOnlineUsers($session);

    // Récupérer les messages de chat pour cette session
    $messages = $this->em->getRepository(Message::class)
        ->findBy(
            ['classroomSession' => $session],
            ['createdAt' => 'ASC']
        );

    return $this->render('classroom/show.html.twig', [
        'session' => $session,
        'onlineUsers' => $onlineUsers,
        'isTeacher' => $session->getTeacher() === $user,
        'messages' => $messages,
    ]);
}

    $hasAccess = $session->getTeacher() === $user ||
                $session->getStudents()->contains($user) ||
                ($session->isOpen() && $session->isActive());

    if (!$hasAccess) {
        throw $this->createAccessDeniedException('You don\'t have access to this session');
    }

    // Marquer l'utilisateur comme en ligne
    $this->presenceService->markUserOnline($user, $session);

    // Récupérer la liste des utilisateurs en ligne
    $onlineUsers = $this->presenceService->getOnlineUsers($session);

    // Récupérer les messages de chat pour cette session
    $messages = $this->em->getRepository(Message::class)
        ->findBy(
            ['classroomSession' => $session],
            ['createdAt' => 'ASC']
        );

    return $this->render('classroom/show.html.twig', [
        'session' => $session,
        'onlineUsers' => $onlineUsers,
        'isTeacher' => $session->getTeacher() === $user,
        'messages' => $messages,
    ]);
}

Une logique simple, mais redoutablement efficace

Cette route ne fait rien de “magique” en apparence :

  • Elle vérifie les droits d’accès (professeur, élève inscrit ou session ouverte),
  • Elle met à jour la présence de l’utilisateur,
  • Elle récupère la liste des utilisateurs en ligne,
  • Et elle charge les messages associés à la session.

Mais ce qui change tout, c’est ce que Symfony UX Turbo fait derrière le rideau.
Grâce au #[Broadcast] sur l’entité Message, chaque nouveau message est automatiquement diffusé via Mercure.
Dès qu’un message est créé, le flux SSE notifie tous les clients abonnés au topic correspondant, et Turbo Stream rafraîchit dynamiquement la vue côté navigateur.

Aucune ligne de JavaScript.
Aucun rafraîchissement manuel.
Juste une application vivante, réactive et élégante.

Facebook Messenger… version classroom

Cette logique donne naissance à une sorte de Messenger pédagogique :

  • les messages sont automatiquement filtrés par session de classe,
  • les échanges sont confinés à leur contexte,
  • et le tout reste synchronisé pour chaque participant connecté.

Chaque utilisateur voit instantanément les nouveaux messages, avec une hiérarchie claire :
les messages du professeur sont mis en avant, tandis que les échanges entre élèves gardent une tonalité plus discrète — un équilibre parfait entre autorité et collaboration.

Démonstration en situation : deux navigateurs, une seule salle de classe

Et voilà le moment magique ✨
Sur la gauche, le professeur.
Sur la droite, l’élève.
Deux navigateurs différents, deux sessions indépendantes… et pourtant, chaque message circule instantanément, sans rechargement.

L’enseignant envoie un message :

« Nous allons parler de Pinia, le Store de Vue.js »

Une fraction de seconde plus tard, le message apparaît dans la fenêtre de l’élève.
L’élève répond :

« Ah oui super, merci ! J’ai hâte. »

Le tout s’affiche en direct, avec un code couleur distinct pour les rôles :

  • Les messages du professeur sont mis en avant (fond violet, tag “Teacher”, badge “Pin” pour les messages importants),
  • Ceux des élèves s’affichent dans un ton plus doux, soulignant la hiérarchie naturelle de la salle de classe.

Présence en temps réel

Dans la section “Online Participants”, la liste se met à jour automatiquement :
chaque utilisateur voit qui est connecté en ce moment, sans rechargement.
Un élève rejoint la classe ? Son nom apparaît immédiatement, accompagné du badge “You” lorsqu’il s’agit de sa propre session.

Grâce à Mercure, la présence et le chat reposent sur les mêmes flux SSE — un channel unique où transitent à la fois les mises à jour de connexion et les messages du chat.

Parlons Turbo : la magie front sans build ni JS

Ce qu’il faut comprendre, c’est que Symfony a complètement repensé sa manière de faire du front.
L’époque où il fallait tout builder avec Webpack, NPM, ou du React embarqué est terminée.

Aujourd’hui, grâce à Symfony UX TurboStimulus et Asset Mapper, tu peux créer une véritable application réactive… avec du Twig pur.
Pas besoin de build, pas de pipeline Webpack :
➡️ Asset Mapper gère tout automatiquement,
➡️ Stimulus est installé par défaut,
➡️ et Turbo fait le reste.

Résultat ?
Tu codes ton interface comme d’habitude — du Twig, un formulaire Symfony — et la magie opère côté navigateur.

Les trois briques Turbo à connaître

Turbo n’est pas une simple “librairie front”. C’est un ensemble cohérent de mécanismes qui pilotent ton DOM sans rechargement complet :

ComposantRôleExemple concret
Turbo DriveGère la navigation fluide entre les pagesLes transitions sont instantanées, sans refresh complet
Turbo FrameEncapsule une portion du DOM à recharger indépendammentIdéal pour des sous-blocs comme le chat ou la liste des participants
Turbo StreamMet à jour dynamiquement des fragments HTML via SSE (Server-Sent Events)C’est ce qu’on utilise ici pour afficher les nouveaux messages en temps réel

Et le plus beau dans tout ça ?
➡️ Tu peux tout combiner pour créer une véritable SPA en Twig, sans écrire une seule ligne de JavaScript personnalisé.

Le template Twig du chat temps réel

Jetons un œil au cœur du front : le template Twig du chat.
C’est ici que tout s’orchestre — Turbo écoute les flux Mercure, injecte les nouveaux messages dans le DOM, et garde le tout parfaitement synchrone.

<!-- Chat Section -->
<div class="row mt-4">
    <div class="col-12">
        <div class="card">
            <div class="card-header">
                <h5 class="mb-0">
                    <i class="fas fa-comments me-2"></i>
                    Classroom Chat
                </h5>
            </div>
            <div class="card-body p-0">
                <div class="flex flex-col" style="height: 400px;">
                    <div id="messages-list" class="flex-1 overflow-y-auto p-4 space-y-3" {{ turbo_stream_listen('/classroom/' ~ session.id ~ '/chat') }}>
                        {% for message in messages %}
                            {% include 'chat/_message.html.twig' %}
                        {% endfor %}
                    </div>

                    <div class="border-top p-3">
                        <form action="{{ path('app_classroom_post_message', {id: session.id}) }}" method="post" enctype="multipart/form-data" id="chat-form">
                            {% if isTeacher %}
                                <div class="d-flex gap-2 mb-2 align-items-center">
                                    <label class="form-check-label" style="display: flex; align-items: center; gap: 0.5rem;">
                                        <input type="checkbox" name="pin_message" id="pin-checkbox" class="form-check-input" style="margin: 0;">
                                        <span>📌 Pin message</span>
                                    </label>
                                    <label class="btn btn-sm btn-outline-secondary" style="cursor: pointer; white-space: nowrap;">
                                        📎 Attach File
                                        <input type="file" name="file" id="file-input" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif,.webp" style="display: none;">
                                    </label>
                                    <span id="file-name" class="text-muted small"></span>
                                    <span class="badge bg-info rounded">Professor</span>
                                </div>
                            {% endif %}
                            <div class="d-flex gap-2">
                                <input
                                    type="text"
                                    name="content"
                                    id="message-input"
                                    placeholder="Type your message..."
                                    class="form-control"
                                    autocomplete="off"
                                >
                                <button
                                    type="submit"
                                    class="btn btn-primary"
                                    style="white-space: nowrap;"
                                >
                                    Send
                                </button>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Analyse du code

  • Le bloc principal #messages-list écoute le topic Mercure dédié au chat via {{ turbo_stream_listen(...) }}.
    → Dès qu’un message est créé, Turbo reçoit un flux SSE et injecte le message dans le DOM, sans reload.
  • Le for message in messages gère l’affichage initial, avant que les flux en direct ne prennent le relais.
  • Le formulaire de chat reste un simple Symfony Form POST,
    mais grâce à Turbo, le DOM se met à jour instantanément après chaque envoi.
  • Le professeur dispose d’options supplémentaires :
    • épingler un message (📌 Pin message),
    • joindre un fichier (📎 Attach File),
    • et son rôle est signalé par un badge clair.

Bref : zéro JavaScript, zéro WebSocket, zéro build.
Juste du Twig et du Turbo.


Conclusion : de Mercure à FrankenPHP, une stack qui s’épure

Après seulement deux versions de ce Proof of Concept, le résultat est déjà bluffant :
on dispose désormais d’une classroom Symfony temps réel, avec gestion de présence, messagerie instantanée, et rôles différenciés entre professeur et élèves.

Grâce à Mercure, la présence des utilisateurs et les échanges s’actualisent sans rechargement, tandis que Symfony UX Turbo s’occupe d’orchestrer les flux SSE directement dans le DOM.
Côté backend, une classe dédiée gère l’état de connexion de chaque utilisateur et le persiste — de sorte que même en cas de déconnexion, les données de présence restent fiables.

Une classe, un vrai chat, et zéro superflu

Dans cette version, on a poussé l’expérience plus loin :

  • les professeurs peuvent épingler un message,
  • joindre un fichier (comme un support de cours),
  • et tous les messages s’affichent avec leur statut et leur hiérarchie visuelle.

C’est littéralement une salle de classe numérique en Twig — sans JavaScript custom, sans WebSocket, sans build.

Adieu Apache, bonjour FrankenPHP

Le vrai tournant, c’est ici :
on a supprimé Apache et le container Mercure du docker-compose.yml pour tout centraliser autour de FrankenPHP.
Ce serveur moderne gère Mercure en natif, simplifiant ainsi la stack et réduisant le nombre de conteneurs à maintenir.

Et la capture Docker Desktop ci-dessous en est la preuve 👇

Symfony frankenPHP Turbo SSE mercure

Une architecture légère, claire, et diablement efficace :

  • frankenphp pour servir l’app Symfony + Mercure,
  • postgres pour la base de données,
  • mailpit pour le testing des mails.

Rien d’autre.
Une stack unifiée, moderne, et performante.

Et maintenant ?

Dans la prochaine partie, on va ajouter un soupçon de folie :
un peu de Machine Learning dans cette classroom, histoire de voir jusqu’où on peut pousser le concept (et parce qu’un POC qui part en vrille, c’est souvent là qu’il devient intéressant 😄).

Un immense merci à Ryan Weaver et à l’équipe de SymfonyCasts pour leur incroyable travail.
Leur pédagogie m’a littéralement fait comprendre pourquoi cette stack “Last Stack” est l’une des plus pertinentes de 2025.

👉 Pour aller plus loin sur Turbo et Symfony UX, je te recommande chaudement leur série :
🔗 https://symfonycasts.com/screencast/last-stack

Cet article t’a plu ?

Là, on traite du SSE avec FrankenPHP dans un POC réaliste mais si tu veux voir FrankenPHP avec de la vidéo je te mets le lien en dessous. Et surtout si tu as aimé l’article, donne moi ton feedback en commentaire, ça fait toujours plaisir.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Content written by jean-Sébastien Christophe Content Creator • Jean-Sébastien Christophe

References

  1. Wikipedia contributors. (2024). "Jean-Sébastien Christophe." Retrieved from https://en.wikipedia.org/wiki/Jean-Sébastien_Christophe
  2. Google. (2024). "Search results for Jean-Sébastien Christophe." Retrieved from https://www.google.com/search?q=Jean-S%C3%A9bastien+Christophe
  3. YouTube. (2024). "Video content about Jean-Sébastien Christophe." Retrieved from https://www.youtube.com/results?search_query=Jean-S%C3%A9bastien+Christophe