, , ,
• •

20 min de lecture

FrankenPHP et Mercure : un SAV e-commerce entre IA et conseiller humain

Construire un SAV e-commerce avec Symfony, FrankenPHP et Mercure

Introduction

Je suis revenu de l’API Platform Conference 2026 avec quelques idées à expérimenter. Parmi les sujets qui ont retenu mon attention : Mercure et les évolutions de sa version 1.0. De quoi me donner envie de reprendre mes expérimentations autour du temps réel avec Symfony et FrankenPHP.

Si vous suivez le blog, vous vous souvenez peut-être de ma classe virtuelle mêlant IA et open source, avec des échanges entre professeur et élèves. Cette fois, j’ai voulu pousser l’expérience vers un autre cas d’usage : le e-commerce. Avec des clients, des commandes et, forcément, du SAV. Sinon, ce ne serait pas drôle.

L’idée est simple : un client pose une question, un assistant l’oriente vers une réponse de FAQ, le suivi de sa commande ou un conseiller. Et lorsqu’un humain reprend la main, la conversation continue dans le même fil.

Nous allons construire ce premier prototype avec Symfony, FrankenPHP et Mercure, en expliquant les choix techniques et leurs limites. Une première étape pour explorer, progressivement, ce qu’il faudrait maîtriser pour aller vers une solution souveraine.


La stack

Pour cette V0, j’ai choisi de passer par une API pour la partie IA. Pas d’auto-hébergement du modèle pour le moment : nous utilisons OpenRouter pour accéder à JEV, chargé d’orienter les demandes des clients. Cela permet de commencer par le fonctionnement du SAV, avant de se pencher sur l’hébergement de l’inférence.

Pour le reste, on retrouve Symfony 8.1, servi par FrankenPHP et son intégration à Caddy. Mercure assure la diffusion des messages en temps réel vers le client et les conseillers. Côté interface, nous restons sur Twig, Turbo et Stimulus, sans ajouter de framework JavaScript.

Nous avons également besoin d’un worker Symfony Messenger. Il intervient dès cette version pour traiter les messages en asynchrone : le traitement lié à l’IA s’effectue en arrière-plan, puis le résultat est publié via Mercure. Messenger prend donc en charge le traitement différé, tandis que Mercure transmet les mises à jour aux navigateurs.

L’ensemble tourne dans Docker Compose, avec trois services définis dans compose.yaml :

  • web : l’application Symfony et le hub Mercure intégré à FrankenPHP ;
  • worker : un conteneur construit à partir de la même image, qui exécute le consommateur Messenger ;
  • database : PostgreSQL 16, pour les données de l’application et le transport Doctrine de Messenger.

Le fichier compose.override.yaml complète cet environnement local avec Mailpit pour les essais d’envoi d’e-mails et expose PostgreSQL à la machine hôte.

Un petit détail de configuration mérite qu’on s’y arrête : le worker publie sur http://web/.well-known/mercure, via le réseau Docker. Le navigateur, lui, se connecte à http://localhost:8080/.well-known/mercure par défaut. C’est le même hub, mais son adresse dépend de l’endroit depuis lequel on y accède.


services:
  web:
    build: .
    # Secrets locaux (OPENROUTER_API_KEY…) : .env.local n'entre pas dans l'image, on le passe au conteneur.
    env_file: &local-env
      - path: .env.local
        required: false
    environment: &app-env
      APP_ENV: dev
      APP_SECRET: ${APP_SECRET:-local-development-secret}
      DATABASE_URL: "postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-16}&charset=utf8"
      MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET:-local-development-mercure-secret-change-me}
      MERCURE_URL: http://127.0.0.1/.well-known/mercure
      MERCURE_PUBLIC_URL: http://localhost:${HTTP_PORT:-8080}/.well-known/mercure
      DEFAULT_URI: http://localhost:${HTTP_PORT:-8080}
    ports:
      - "${HTTP_PORT:-8080}:80"
    depends_on:
      database:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "php", "-r", "exit(@file_get_contents('http://127.0.0.1/health') === false ? 1 : 0);"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  # Traite les messages async (réponses de JEV, e-mails) et publie sur le hub Mercure du service web.
  worker:
    build: .
    command: ["php", "bin/console", "messenger:consume", "async", "-vv", "--time-limit=3600"]
    restart: unless-stopped
    # Le healthcheck de l'image FrankenPHP vise le serveur web, absent ici.
    healthcheck:
      disable: true
    env_file: *local-env
    environment:
      <<: *app-env
      MERCURE_URL: http://web/.well-known/mercure
    depends_on:
      web:
        condition: service_healthy

###> doctrine/doctrine-bundle ###
  database:
    image: postgres:${POSTGRES_VERSION:-16}-bookworm
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-app}
      # You should definitely change the password in production
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
      POSTGRES_USER: ${POSTGRES_USER:-app}
    healthcheck:
      test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
      timeout: 5s
      retries: 5
      start_period: 60s
    volumes:
      - database_data:/var/lib/postgresql/data:rw
      # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
      # - ./docker/db/data:/var/lib/postgresql/data:rw
###< doctrine/doctrine-bundle ###


volumes:
###> doctrine/doctrine-bundle ###
  database_data:
###< doctrine/doctrine-bundle ###
### compose.override.yaml
services:
###> doctrine/doctrine-bundle ###
  database:
    ports:
      - "5432"
###< doctrine/doctrine-bundle ###

###> symfony/mailer ###
  mailer:
    image: axllent/mailpit
    ports:
      - "1025"
      - "8025"
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ###

La V0 et pourquoi ces choix

Pour cette V0, j’ai choisi de partir d’un scénario simple : un client ouvre une demande de SAV depuis une commande. L’assistant l’oriente et, si nécessaire, un conseiller humain prend le relais dans la même conversation.

Pour me concentrer sur ce parcours, j’ai volontairement limité le périmètre :

  • Les clients et les commandes sont générés à l’aide de fixtures.
  • L’accès à un compte client se fait en un clic, sans saisie d’identifiants. Ce fonctionnement est réservé à la démonstration.
  • Les conseillers disposent d’un espace dédié pour prendre en charge les demandes.
  • Le SAV se limite à la discussion : aucun remboursement, aucune annulation ni modification de commande ne sont déclenchés depuis le chat.

Ce dernier point est important : à ce stade, je veux observer comment une demande est orientée et comment un conseiller reprend l’échange. Les actions sur les commandes viendront éventuellement plus tard, avec leurs propres règles métier.

Pour la démonstration, j’ai sélectionné un client au hasard, ouvert l’une de ses commandes, puis cliqué sur « Un souci avec cette commande ? ».

Le chat s’ouvre alors avec la commande associée à la demande. Voici le déroulement de cet échange en vidéo.

Dans cet exemple, l’IA a directement orienté la demande vers un conseiller. L’application transmet alors la demande au SAV et informe la cliente dans le chat. Mercure diffuse ces mises à jour en temps réel grâce aux Server-Sent Events (SSE), sans rechargement de la page.

Pour simplifier cette V0, le client et le conseiller partagent un même topic privé, propre à la conversation. Lorsque le conseiller rejoint l’échange, il poursuit la discussion dans le fil existant : la cliente n’a pas besoin d’ouvrir une nouvelle demande.

Et côté support ?

À partir de là, un conseiller humain prend le relais. L’IA a servi à orienter la demande ; la suite de l’échange se déroule directement entre le client et le support.

C’est ce que je voulais explorer avec cette V0 : une assistance ciblée, capable de faciliter le premier contact et de laisser la place à un humain lorsque la demande le nécessite.

Concrètement, les conseillers connectés reçoivent une notification en temps réel lorsqu’une nouvelle demande leur est transmise. L’un d’eux peut alors la prendre en charge et rejoindre la conversation. Côté client, l’échange continue dans le même fil.

Dans la vidéo ci-dessous, j’utilise deux navigateurs distincts : l’un connecté avec un compte conseiller, l’autre avec un compte client. On voit les messages apparaître de part et d’autre, sans recharger les pages. Les échanges sont également persistés en base de données : ils restent donc consultables après un rechargement ou lors d’un retour sur la conversation.

Nous avons ainsi un premier socle de messagerie entre le client et le support, avec une orientation automatisée en amont et une reprise humaine intégrée au parcours.


JEV, l’IA qui ne génère pas de texte !

Oui, vous avez bien lu : aucune réponse rédigée par JEV. Son rôle est de retourner une décision structurée à partir du contexte et des choix que nous lui fournissons. L’application peut ensuite exploiter directement ce résultat.

Dans notre SAV, nous lui demandons d’orienter la demande vers trois possibilités : une réponse de FAQ, le suivi d’une commande ou un conseiller humain. Les choix proposés au client lorsque le modèle hésite sont, eux, gérés par notre application.

Nous utilisons pour cela la primitive Choice, qui retourne une option sélectionnée, les probabilités associées aux différentes options et un score de confiance. Dans cette V0, lorsque ce dernier est inférieur à 0,6, l’application présente les deux options les plus probables au client. Ce seuil reste expérimental : je l’ai fixé après quelques essais, et il faudra le confronter à de vraies demandes.

Côté client, c’est Bloppy qui répond. Derrière ce nom, pas de modèle : des phrases écrites à l’avance, rangées dans un enum PHP. L’application choisit la bonne en fonction de la décision de JEV, puis la complète avec les données disponibles, comme le statut de la commande. La réponse s’affiche instantanément, et aucune phrase visible dans le chat n’est générée. Bloppy tient en fait la place d’un futur LLM : quand la génération arrivera, c’est lui qui évoluera, pas l’orientation faite par JEV.

C’est cette séparation qui m’intéresse : utiliser le modèle pour orienter une demande, puis laisser le code appliquer le comportement prévu. Cela ne garantit pas que l’orientation sera toujours correcte, mais permet de définir précisément ce que l’application peut faire à partir du résultat.

De la demande client à une décision exploitable

Toute l’intégration est regroupée dans le service JevAssistant. Son rôle : préparer le contexte, interroger JEV via OpenRouter, puis retourner un objet JevDecision.

Le contexte envoyé mérite qu’on s’y arrête. La méthode state() récupère uniquement les messages du client. Lorsqu’une commande est associée à la conversation, elle ajoute sa référence, sa date, son statut ainsi que les noms et catégories des produits concernés. JEV dispose donc d’informations pour interpréter une demande comme « Je n’ai toujours rien reçu ».

Nous lui soumettons ensuite deux questions de type choice dans un même appel : vers quelle orientation diriger le client, et quelle rubrique de FAQ correspond le mieux à sa demande ?

Les options sont construites à partir de nos enums SupportRoute et FaqTopic. Pour l’orientation, chaque valeur est accompagnée de sa description métier, fournie par criteria(). C’est ici que nous expliquons au modèle ce que représentent les différentes possibilités.

Au retour de l’API, les choix sont convertis en valeurs d’enum PHP, puis regroupés dans un objet JevDecision. Le reste de l’application reçoit ainsi un résultat structuré, sans avoir à manipuler directement la réponse d’OpenRouter.

Le score de confiance de l’orientation intervient également. En dessous de 0.6, le service trie les probabilités et récupère la deuxième orientation la plus probable. Il l’ajoute à la décision pour permettre à Bloppy de proposer une alternative au client. Ce seuil est un premier réglage pour la démonstration, pas une valeur validée sur un jeu de demandes représentatif.

Enfin, si la clé API est absente ou qu’une exception survient pendant l’appel ou l’interprétation du résultat, le service journalise l’erreur et retourne une orientation vers un conseiller. L’échec du modèle dispose donc lui aussi d’un chemin prévu dans l’application.

<?php

namespace App\Jev;

use App\Entity\SupportConversation;
use App\Enum\FaqTopic;
use App\Enum\MessageAuthor;
use App\Enum\SupportRoute;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * JEV (TypeSafe), via l'API Decisions d'OpenRouter. Il ne rédige pas : il choisit une orientation parmi SupportRoute
 * et une rubrique de FAQ, avec des probabilités.
 *
 * @see https://openrouter.ai/docs/guides/community/jev-tutorial
 */
final class JevAssistant
{
    private const string ENDPOINT = 'https://openrouter.ai/api/alpha/decisions';

    /** En dessous, JEV hésite : Bloppy propose les deux orientations les plus probables. */
    private const float MIN_CONFIDENCE = 0.6;

    public function __construct(
        private HttpClientInterface $httpClient,
        private LoggerInterface $logger,
        #[Autowire(env: 'OPENROUTER_API_KEY')]
        private string $apiKey,
        #[Autowire(env: 'OPENROUTER_MODEL')]
        private string $model,
    ) {
    }

    public function decide(SupportConversation $conversation): JevDecision
    {
        try {
            if ('' === $this->apiKey) {
                throw new \RuntimeException('OPENROUTER_API_KEY is not set.');
            }

            $answers = $this->httpClient->request('POST', self::ENDPOINT, [
                'auth_bearer' => $this->apiKey,
                'timeout' => 10,
                'json' => [
                    'model' => $this->model,
                    'state' => $this->state($conversation),
                    'questions' => $this->questions(),
                ],
            ])->toArray()['answers'];

            return new JevDecision(
                SupportRoute::from($answers['route']['choice']),
                FaqTopic::from($answers['faq_topic']['choice']),
                $answers['route']['confidence'] < self::MIN_CONFIDENCE ?    $this->runnerUp($answers['route']['probabilities']) : null,
            );
        } catch (\Throwable $e) {
            $this->logger->error('JEV could not decide, handing over to an advisor.', ['exception' => $e]);

            return new JevDecision(SupportRoute::Advisor, FaqTopic::Warranty);
        }
    }

    /**
     * @return array<string, mixed>
     */
    private function state(SupportConversation $conversation): array
    {
        $messages = [];
        foreach ($conversation->getMessages() as $message) {
            if (MessageAuthor::Customer === $message->getAuthor()) {
                $messages[] = $message->getContent();
            }
        }

        $state = ['messages_du_client' => $messages];
        if ($order = $conversation->getOrder()) {
            $products = [];
            foreach ($order->getItems() as $item) {
                $products[] = $item->getProduct()->getName().' ('.$item->getProduct()->getCategory().')';
            }
            $state['commande_concernee'] = \sprintf('%s du %s, statut « %s » : %s', $order->getReference(), $order->getCreatedAt()->format('d/m/Y'), $order->getStatus()->label(), implode(', ', $products));
        }

        return $state;
    }

    /**
     * Deux questions, traitées en parallèle par JEV : l'orientation, et la rubrique de FAQ la plus proche.
     *
     * @return array<string, mixed>
     */
    private function questions(): array
    {
        $routes = [];
        foreach (SupportRoute::cases() as $route) {
            $routes[$route->value] = $route->criteria();
        }

        $topics = [];
        foreach (FaqTopic::cases() as $topic) {
            $topics[$topic->value] = $topic->label();
        }

        return [
            'route' => ['type' => 'choice', 'instructions' => 'Où orienter ce client du SAV d\'une boutique d\'électroménager ?', 'criteria' => $routes],
            'faq_topic' => ['type' => 'choice', 'instructions' => 'Quelle rubrique de la FAQ se rapproche le plus de la demande du client ?', 'criteria' => $topics],
        ];
    }

    /**
     * @param array<string, float> $probabilities
     */
    private function runnerUp(array $probabilities): SupportRoute
    {
        arsort($probabilities);

        return SupportRoute::from(array_keys($probabilities)[1]);
    }
}

Les limites de cette V0

Cette première version reste simple, mais elle permet déjà de voir les prochaines étapes. La limite la plus visible concerne les réponses de Bloppy : les textes sont définis à l’avance dans le code, ce qui donne parfois à l’échange un côté un peu mécanique.

C’est un compromis assumé pour valider le parcours. Nous pourrons ensuite travailler des formulations plus naturelles, puis évaluer l’intérêt d’un modèle de génération adapté. Pas besoin de sortir l’artillerie lourde pour reformuler un statut de commande : l’objectif sera d’améliorer la conversation en gardant la maîtrise des informations transmises et des coûts.

Pour le moment, les échanges se limitent également au texte. Impossible de joindre une photo d’un appareil endommagé ou un document, alors que ces éléments peuvent être utiles au traitement d’une demande de SAV.

Côté support, le tableau de bord permet de prendre en charge une demande et de poursuivre la discussion, mais la gestion reste sommaire : pas encore de clôture de conversation ni de suivi détaillé du traitement. JEV pourrait aussi trouver une place à ce niveau, par exemple pour proposer une catégorie ou un niveau de priorité aux conseillers. Ce sera une piste à tester.

Enfin, la qualité de l’orientation reste à évaluer sur des demandes représentatives. Le seuil de confiance de 0,6 repose sur quelques essais, et les tests automatisés utilisent une simulation de JEV. Ils vérifient le comportement de l’application, mais ne suffisent pas à mesurer la pertinence des décisions du modèle en situation réelle.

Barre de debug Symfony montrant le flux SSE Mercure d'une conversation SAV.
Barre de debug Symfony : les messages partent en requêtes Ajax, les réponses arrivent par le flux SSE du hub Mercure.

Conclusion

J’ai beaucoup appris en construisant ce POC, notamment sur l’articulation entre traitement asynchrone, décision automatisée et échanges en temps réel. L’intégration de Mercure à FrankenPHP facilite la mise en place : le hub se configure directement dans le Caddyfile, ce qui nous permet ici de regrouper l’application et la diffusion des événements dans le même service.

JEV trouve sa place dans ce parcours avec un rôle précis : proposer une première orientation à partir de la demande du client. L’application garde la main sur les réponses affichées et le conseiller poursuit l’échange lorsque son intervention est nécessaire. C’est cette répartition des responsabilités que je voulais expérimenter dans cette V0.

La suite se jouera dans une V1, avec Symfony AI et une autre piste que je garde encore un peu sous le coude. L’objectif reste de construire progressivement un support e-commerce plus complet, en confrontant chaque évolution à son utilité pour le client comme pour les conseillers. Il reste du travail, et c’est justement ce qui rend la suite intéressante.

Et vous, avez-vous déjà intégré une IA pour orienter les demandes de vos utilisateurs, avec Mercure pour les échanges en temps réel ? Je suis curieux de connaître vos approches et vos retours d’expérience.