Introduction :
Le principal frein dans une application PHP classique ? Le traitement synchrone. Lorsqu’un utilisateur déclenche une action impliquant un volume important de données — comme envoyer un e-mail, valider une commande ou effectuer une analyse — le serveur est bloqué jusqu’à la fin du traitement. Résultat : des délais, des timeouts, et une expérience utilisateur dégradée.
C’est là que l’asynchrone entre en scène.
Symfony intègre une solution puissante avec son composant Messenger, qui permet d’envoyer et de consommer des messages via divers transports. Si vous débutez, vous pourriez être tenté d’utiliser Doctrine comme transport. Pratique pour les petits projets, mais loin d’être scalable : votre base de données gère à la fois les opérations métier classiques (CRUD) et le traitement des messages… un cocktail explosif pour la montée en charge.
Heureusement, il existe une alternative robuste, open source, pensée pour la performance : RabbitMQ. Véritable message broker, il est conçu pour absorber des milliers de messages à la seconde, assurer leur livraison et fiabiliser l’asynchrone de votre stack.
Dans cet article, on va mettre en place un environnement complet avec Symfony, RabbitMQ et FrankenPHP pour une architecture PHP moderne, rapide, et surtout scalable. Au programme : envoi d’e-mails, et action commerciales, et une stack prête pour le passage à l’échelle.
RabbitMQ : une flexibilité redoutable grâce aux types de files (queues)
Ce qui fait la puissance de RabbitMQ, c’est sa versatilité dans la gestion des files de messages. Contrairement à d’autres systèmes plus rigides, RabbitMQ permet une configuration fine et adaptée à tous les scénarios métiers, des plus simples aux plus complexes.
📨 File simple : le cas classique
Prenons un exemple basique : un utilisateur passe commande et reçoit un e-mail de confirmation. Ici, une queue simplesuffit : l’événement déclenche l’envoi d’un message, qui est ensuite consommé par un handler chargé d’expédier le mail.
Mais ce serait sous-estimer ce que RabbitMQ peut vraiment offrir…
🎯 Topics : des files contextuelles, puissantes et évolutives
Imaginez maintenant un workflow plus intelligent :
- L’utilisateur valide sa commande.
- Le paiement passe par Stripe.
- Un webhook Stripe confirme la transaction.
- En fonction de l’abonnement à la newsletter, le message à envoyer peut changer :
payment.completed.newsletter_subscriber
➜ envoi du mail + coupon.payment.completed.standard
➜ simple mail de confirmation.- Et demain ? On peut très facilement ajouter :
payment.completed.vip
payment.failed
👉 On parle ici de topics, un système de routage avancé où chaque message peut être aiguillé en fonction d’un contexte précis, sans modifier la structure de base.
🔧 Avantages des topics :
- Flexibilité : chaque événement peut avoir plusieurs déclinaisons (
payment.completed.*
,user.registered.*
, etc.). - Évolutivité : ajouter un cas d’usage ne casse rien, il suffit d’ajouter une routing key et un handler.
- Séparation logique : chaque type de message est traité par un service spécifique, ce qui rend votre code plus clair, modulaire et maintenable.
Et ce n’est que le début. RabbitMQ offre aussi une gestion fine des erreurs : si un message échoue, il peut être routé vers une dead-letter queue, loggé, ou même renvoyé après un certain temps.
FrankenPHP & Docker : un environnement rapide, fiable et modulaire
Dans mon précédent article — un vrai overkill assumé — je mettais en place un environnement local complet, digne d’un cloud provider, avec du Docker, du ELK, et des microservices à gogo.
Ici, retour aux bases : faire tourner une stack performante localement, avec RabbitMQ et FrankenPHP comme piliers de l’environnement. Et cette fois, on ne cherche pas à tout virtualiser… mais à tester sérieusement une architecture scalable.
🔧 Objectif : simplicité, efficacité
Grâce à Docker, on déploie en quelques secondes un setup complet incluant :
- FrankenPHP : serveur HTTP moderne, rapide et compatible Symfony.
- PostgreSQL (via Alpine) : base de données légère et performante.
- PGAdmin : interface web pour gérer la base PostgreSQL facilement.
- RabbitMQ : notre broker de messages, prêt à absorber la charge.
- Redis : cache ultra-rapide pour le transport synchronisé (ou pour les tests).
Tout est lancé via un simple docker-compose up -d
et… tout fonctionne, out of the box. Plus besoin de serveur Apache ou Nginx, FrankenPHP tourne en mode worker, parfait pour traiter nos messages asynchrones à haute vitesse.
Docker Compose : une stack complète pour Symfony + RabbitMQ + FrankenPHP
Pour mettre en place notre environnement de test, voici un fichier docker-compose.yml
prêt à l’emploi. Il combine FrankenPHP comme serveur principal, RabbitMQ pour la gestion des messages asynchrones, Redis, PostgreSQL, et même Mercure pour le temps réel :
# docker-compose.yml
services:
php:
build:
context: .
dockerfile: docker/Dockerfile
volumes:
- .:/app
- ./docker/php.ini:/usr/local/etc/php/conf.d/app.ini
- ./docker/Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
- ./docker/certs:/etc/caddy/certs
ports:
- "8080:80"
- "443:443"
depends_on:
- database
- rabbitmq
environment:
- APP_ENV=dev
- APP_DEBUG=1
- SERVER_NAME=localhost:443
- DATABASE_URL=postgresql://app:password@database:5432/app
- MERCURE_URL=http://php/.well-known/mercure
- MERCURE_PUBLIC_URL=http://localhost/.well-known/mercure
- MERCURE_JWT_SECRET=!ChangeThisMercureHubJWTSecretKey!
- REDIS_URL=redis://redis:6379
networks:
- app_network
database:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: password
POSTGRES_USER: app
POSTGRES_DB: app
volumes:
- database_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- app_network
pgadmin:
image: dpage/pgadmin4
environment:
PGADMIN_DEFAULT_EMAIL: admin@example.com
PGADMIN_DEFAULT_PASSWORD: admin
ports:
- "5050:80"
depends_on:
- database
networks:
- app_network
redis:
image: redis:alpine
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- app_network
rabbitmq:
image: rabbitmq:3.12-management-alpine
container_name: symfony-rabbitmq
hostname: rabbitmq
environment:
- RABBITMQ_DEFAULT_USER=${RABBITMQ_USER:-user}
- RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD:-password}
- RABBITMQ_DEFAULT_VHOST=${RABBITMQ_VHOST:-/}
ports:
- "5672:5672"
- "15672:15672" # Interface d'administration
volumes:
- rabbitmq_data:/var/lib/rabbitmq
restart: unless-stopped
networks:
- app_network
mailer:
image: schickling/mailcatcher
ports:
- "1025:1025"
- "1080:1080"
networks:
- app_network
###> symfony/mercure-bundle ###
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
# Uncomment the following line to disable HTTPS,
#SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeThisMercureHubJWTSecretKey!'
# Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://127.0.0.1:8000
# Comment the following line to disable the development mode
command: /usr/bin/caddy run --config /etc/caddy/dev.Caddyfile
healthcheck:
test: ["CMD", "curl", "-f", "https://localhost/healthz"]
timeout: 5s
retries: 5
start_period: 60s
volumes:
- mercure_data:/data
- mercure_config:/config
networks:
- app_network
###< symfony/mercure-bundle ###
volumes:
database_data:
redis_data:
rabbitmq_data:
caddy_data:
caddy_config:
###> symfony/mercure-bundle ###
mercure_data:
mercure_config:
###< symfony/mercure-bundle ###
# Section réseau ajoutée
networks:
app_network:
driver: bridge
💡 À retenir :
- FrankenPHP remplace Nginx/Apache et fonctionne directement en mode worker.
- RabbitMQ est exposé sur les ports
5672
(transports) et15672
(interface d’admin). - PgAdmin accessible sur
http://localhost:5050
pour visualiser vos données PostgreSQL. - Mailcatcher pour simuler l’envoi de mails (ports
1025/1080
). - Mercure pour du temps réel si nécessaire.
Ce setup vous donne un environnement de développement PHP moderne, complet et prêt pour la production après quelques ajustements. Il constitue la base idéale pour tester la scalabilité de vos traitements asynchrones.
Générer des données réalistes en un clin d’œil grâce aux Factory
Avant de tester sérieusement notre système de messages asynchrones, il nous faut des données métier solides. Plutôt que de passer par les traditionnelles fixtures Doctrine, on utilise les Factory du bundle Zenstruck Foundry. Et autant le dire tout de suite : c’est rapide, fluide, et bien plus maintenable.
Nos entités : un écosystème e-commerce basique
Pour cet exemple, on dispose de quatre entités principales :
User
: l’utilisateurProduct
: les produits du catalogueOrder
: les commandes passéesOrderItems
: les items présents dans une commande
Les Factory : la méthode rapide, propre et compatible Doctrine
Avec Zenstruck Foundry, la création de fixtures devient intuitive. Voici un exemple de fichier AppFixtures.php
:
namespace App\DataFixtures;
use App\Factory\OrderFactory;
use App\Factory\OrderItemsFactory;
use App\Factory\ProductFactory;
use App\Factory\UserFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager): void
{
UserFactory::createMany(10);
ProductFactory::createMany(10);
OrderFactory::createMany(50, function() {
return [
'client' => UserFactory::random(),
];
});
OrderItemsFactory::createMany(150, function() {
return [
'orderItems' => OrderFactory::random(),
// 'product' => ProductFactory::random(), // optionnel
];
});
$manager->flush();
}
}
✅ 100% compatible Doctrine
🧪 Facile à tester
⚙️ Personnalisation simple avec des closures
💬 Génération réaliste de données pour simuler des cas métier concrets
Avec ça, on est prêt à tester nos files RabbitMQ avec de vraies données représentatives, et à simuler des événements comme order.completed
, payment.failed
ou user.subscribed
.
Messenger : la meilleure passerelle entre Symfony et RabbitMQ
Pourquoi utiliser Messenger ? Parce qu’il est natif dans Symfony, pré-configuré, et surtout parfaitement compatible avec RabbitMQ. En plus d’agir comme un intermédiaire entre votre code métier et le transport AMQP, il offre une séparation propre des responsabilités : on se concentre sur la logique métier via des messages, et Messenger se charge du reste.
<?php
namespace App\Message;
class ThankYouEmailBatchMessage
{
/**
* @param array<int> $userIds Liste des IDs des utilisateurs à traiter (max 10)
*/
public function __construct(
private array $userIds
) {}
public function getUserIds(): array
{
return $this->userIds;
}
public function getCount(): int
{
return count($this->userIds);
}
}
Ici, on prévoit des envois d’e-mails groupés à des utilisateurs ayant payé. C’est propre, structuré, testable.
Côté handler : traitement asynchrone + logging + fiabilité
<?php
namespace App\MessageHandler;
use App\Message\ThankYouEmailBatchMessage;
use App\Repository\UserRepository;
use Psr\Log\LoggerInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Email;
#[AsMessageHandler]
class ThankYouEmailBatchMessageHandler
{
public function __construct(
private UserRepository $userRepository,
private MailerInterface $mailer,
private LoggerInterface $logger
) {}
public function __invoke(ThankYouEmailBatchMessage $message): void
{
$this->logger->info(sprintf('Processing thank you email batch for %d users', $message->getCount()));
// Récupérer les utilisateurs par batch de 10
$users = $this->userRepository->findBy(['id' => $message->getUserIds()]);
foreach ($users as $user) {
try {
// Créer et envoyer l'email de remerciement
$email = (new Email())
->from('noreply@example.com')
->to($user->getEmail())
->subject('Merci pour votre commande !')
->text('Cher client, merci pour votre commande. Elle a été payée avec succès.')
->html('<p>Cher client,</p><p>Merci pour votre commande. Elle a été payée avec succès.</p>');
$this->mailer->send($email);
$this->logger->info(sprintf('Thank you email sent to user %s (%s)', $user->getId(), $user->getEmail()));
// Simulation d'un petit délai pour éviter la surcharge
usleep(100000); // 0.1 seconde
} catch (\Exception $e) {
$this->logger->error(sprintf('Failed to send thank you email to user %s: %s', $user->getId(), $e->getMessage()));
}
}
$this->logger->info('Thank you email batch processing completed');
}
}
Commande pour simuler l’envoi en conditions réelles
<?php
namespace App\Command;
use App\Message\HelloWorldMessage;
use App\Message\ThankYouEmailBatchMessage;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsCommand(
name: 'app:test-rabbitmq',
description: 'Test RabbitMQ with different message types',
)]
class TestRabbitMQCommand extends Command
{
public function __construct(
private MessageBusInterface $bus,
private UserRepository $userRepository,
private EntityManagerInterface $entityManager
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
// 1. Test Hello World
$io->section('Sending Hello World message');
$this->bus->dispatch(new HelloWorldMessage('Hello from command!'));
$io->success('Hello World message dispatched to RabbitMQ');
// 2. Test Thank You emails - clients ayant payé
$io->section('Sending Thank You emails to paying customers');
// Trouver les clients qui ont des commandes payées
$paidUserIds = $this->entityManager->createQuery('
SELECT DISTINCT IDENTITY(o.client) as user_id
FROM App\Entity\Order o
WHERE o.is_paid = true
')->getResult();
if (empty($paidUserIds)) {
$io->warning('No paying customers found. Creating some test data...');
// Pour test, prendre les premiers users
$allUsers = $this->userRepository->findBy([], null, 10);
$paidUserIds = array_map(fn($user) => ['user_id' => $user->getId()], $allUsers);
}
// Découper en batches de 10
$userIds = array_column($paidUserIds, 'user_id');
$batches = array_chunk($userIds, 10);
foreach ($batches as $batch) {
$this->bus->dispatch(new ThankYouEmailBatchMessage($batch));
$io->info(sprintf('Dispatched batch of %d thank you emails', count($batch)));
}
$io->success(sprintf('Dispatched %d batches for thank you emails', count($batches)));
// Instructions pour la consommation
$io->note([
'Messages have been sent to RabbitMQ!',
'To process them, run in another terminal:',
' php bin/console messenger:consume async -vv',
'',
'You can also check emails at: http://localhost:8025 (MailPit)'
]);
return Command::SUCCESS;
}
}
🛠️ Dans un environnement de prod, on remplacerait le consume
manuel par Supervisor ou systemd pour lancer les workers en continu. Mais pour le dev, cette commande suffit à tester l’asynchrone de bout en bout.
Résultat en images : RabbitMQ en action + mails reçus
Pour terminer, voici la preuve en images que tout fonctionne comme prévu dans notre stack asynchrone Symfony + RabbitMQ + FrankenPHP.
🎯 Interface d’administration RabbitMQ
L’interface de gestion, accessible via http://localhost:15672
, permet de suivre en temps réel les files, les messages en attente, les consommateurs, et les échanges.
On y voit clairement nos messages ThankYouEmailBatchMessage
dispatchés dans la file async
.

🔍 Ce que montre cette capture :
- Le graphe des messages prouve que les batchs sont bien publiés.
- Aucun message bloqué : les handlers Symfony consomment bien en temps réel.
- RabbitMQ tourne de manière stable (consommation mémoire faible, uptime de 41 minutes).
- Pas de message redelivré ni unroutable : la configuration est propre et fonctionnelle.
Ce type de visualisation est crucial en production pour déboguer rapidement, monitorer la charge, et optimiser les performances de votre système de messagerie.
📬 Réception des mails dans Mailpit
Une fois les messages consommés par Symfony Messenger, les e-mails sont générés et envoyés. On peut les consulter directement sur Mailpit à l’adresse http://localhost:1080
.
Les e-mails de remerciement ont bien été envoyés aux clients ayant finalisé leur commande. C’est le genre de feedback immédiat qui rend les tests très concrets.

On vient de voir comment et pourquoi mettre en place une file de messages RabbitMQ dans un projet Symfony. Et autant le dire : la base est posée, et elle est solide.
RabbitMQ prouve toute sa puissance dans un contexte concret, loin des exemples en console abstraits. Ici, on a une vraie logique métier, des users, des commandes, des envois d’e-mails. Et tout fonctionne en mode asynchrone, fiable et scalable.
L’apport de Zenstruck Foundry est également déterminant. Pour ce genre de POC (et même en prod !), il permet de générer des jeux de données réalistes en quelques lignes, tout en restant compatible Doctrine.
Conclusion : On a mis en place
🐳 Docker & Networking
- Mise en place d’un
docker-compose
multi-services - Résolution DNS inter-conteneurs
- Mapping des ports et communication interne
- Stack : FrankenPHP + PostgreSQL + RabbitMQ + MailPit
🐰 RabbitMQ & Symfony Messenger
- Transport AMQP configuré dans Messenger
- Messages + Handlers bien séparés
- File simple (Hello World) et file batch (emails groupés)
- Utilisation du pattern Publish/Subscribe
📧 Système de mails
- Symfony Mailer connecté à MailPit
- Traitement asynchrone des emails
- Mails de remerciement, ( pour les autres voir le dépôt GIT)
🗃️ Base de données & Fixtures
- Entités :
User
,Order
,OrderItems
,Product
- Génération de fixtures avec Foundry
- Segmentations des utilisateurs via DQL
⚡ Architecture asynchrone
- Commandes CLI pour orchestrer les campagnes
- Séparation nette entre producteurs et consommateurs
- Traitement en batchs pour éviter les surcharges mémoire
- Logging précis et gestion d’erreurs intégrée
Bien sûr, aucun article n’est parfait. Ce retour d’expérience n’est que ma vision personnelle, une petite contribution à l’écosystème open source PHP.
Mais une chose est sûre : RabbitMQ couplé à FrankenPHP, c’est un vrai game changer. Une stack moderne, rapide, modulaire — et surtout, taillée pour le passage à l’échelle.
Cet article t’a plu ?
Sur mon blog on parle de Symfony, j’ai récemment fait un article sur le fait de créer un chat avec mercure
Laisser un commentaire