/event-subscribe — Event Subscriber Symfony
Utilise quand une classe orchestre plusieurs callbacks liés (before/after filter, multi-événements). Pas quand un seul callback suffit →
/symfony:event-listen. Pas quand tu crées/dispatches l'événement →/symfony:event-dispatch.
Tu crées une classe qui centralise la réaction à un ou plusieurs événements du kernel (ou custom) en implémentant EventSubscriberInterface. Le subscriber déclare lui-même ses abonnements via getSubscribedEvents() — c'est le bon choix quand la logique couvre plusieurs événements liés ou quand on veut que la classe documente elle-même ce à quoi elle répond.
Détection préalable (obligatoire)
- Lire
composer.jsonà la racine — vérifiersymfony/event-dispatcher(généralement tiré parsymfony/framework-bundle).- Absent → afficher : « Ce skill cible Symfony, je ne trouve pas
symfony/event-dispatcher. On continue quand même ou on change d'approche ? » et attendre.
- Absent → afficher : « Ce skill cible Symfony, je ne trouve pas
- Lire
config/services.yamlpour vérifier_defaults.autoconfigure: true(indispensable : c'est l'autoconfiguration qui taguekernel.event_subscribersans config). - Si l'événement visé est un événement custom, vérifier qu'il est dispatché quelque part (
grep -r "dispatch(" src/). Sinon renvoyer vers/symfony:event-dispatch.
Règles fondamentales
- Subscriber vs Listener :
- Subscriber = classe qui déclare elle-même ses événements via
getSubscribedEvents(). Préféré quand la classe réagit à plusieurs événements liés ou qu'on veut auto-documenter les abonnements. - Listener = classe taggée par configuration (YAML ou
#[AsEventListener]). Préféré pour un écouteur mono-événement, pour un code qu'on veut activer/désactiver par config, ou dans un bundle partagé. Voir/symfony:event-listen.
- Subscriber = classe qui déclare elle-même ses événements via
- Nom d'événement = FQCN : toujours utiliser la classe de l'événement (
RequestEvent::class) plutôt que la constante string ('kernel.request'). Type-safe, refactorable, explicite. Les constantesKernelEvents::REQUESTsont acceptables comme alias lisible. - Priorité = int : plus grand = exécuté plus tôt. Défaut
0. Les écouteurs internes Symfony vivent dans[-256, 256]. Pour s'insérer, liredebug:event-dispatcher <event>et choisir une priorité entre les voisins. - Une méthode courte par abonnement : la méthode callback reçoit l'événement, extrait ce dont elle a besoin, délègue le vrai travail à un service injecté. On ne met pas de logique métier lourde dans la méthode elle-même.
declare(strict_types=1)en tête, classefinal, constructeur typehinté (injection standard Symfony — l'autoconfigure tague enkernel.event_subscribermême sur une classefinal).- Pas de
staticen dehors degetSubscribedEvents(): cette méthode est statique par contrat. Le reste de la classe est normal. - Ne pas stocker d'état entre événements : un subscriber est un service, donc singleton. Si un événement doit passer une info à un autre, passer par l'objet
Eventlui-même ouRequest::$attributes.
Structure minimale
// src/EventSubscriber/OrderCreatedSubscriber.php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Event\OrderCreatedEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class OrderCreatedSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
OrderCreatedEvent::class => 'onOrderCreated',
];
}
public function onOrderCreated(OrderCreatedEvent $event): void
{
// réaction courte, délégation à un service si besoin
}
}
Placer sous src/EventSubscriber/ (convention Symfony Flex — le resource App\: couvre le dossier, l'autoconfigure pose le tag kernel.event_subscriber). Aucune ligne de config YAML.
Formes de retour de getSubscribedEvents()
Trois syntaxes acceptées, toutes valides :
public static function getSubscribedEvents(): array
{
return [
// 1) Un callback sans priorité
OrderCreatedEvent::class => 'onOrderCreated',
// 2) Un callback avec priorité (int)
ProductUpdatedEvent::class => ['onProductUpdated', 100],
// 3) Plusieurs callbacks sur le même événement, chacun avec sa priorité
ExceptionEvent::class => [
['logException', 10],
['transformToJson', 0],
['notifyOps', -10],
],
];
}
Quand l'ordre importe entre callbacks internes (ex : logger avant transformer pour capturer l'exception brute), passer par la forme 3. Plus grand = plus tôt.
Pattern 1 : before/after filter via kernel events
Cas d'usage typique : valider un token d'auth sur certaines routes (kernel.controller) puis signer la réponse (kernel.response). Les deux réactions partagent l'état via Request::$attributes.
// src/EventSubscriber/TokenSubscriber.php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Controller\TokenAuthenticatedController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
final class TokenSubscriber implements EventSubscriberInterface
{
public function __construct(
#[Autowire(param: 'app.api_tokens')]
private readonly array $tokens,
) {}
public function onKernelController(ControllerEvent $event): void
{
$controller = $event->getController();
if (is_array($controller)) {
$controller = $controller[0];
}
if (!$controller instanceof TokenAuthenticatedController) {
return;
}
$token = $event->getRequest()->query->get('token');
if (!in_array($token, $this->tokens, true)) {
throw new AccessDeniedHttpException('Invalid token');
}
$event->getRequest()->attributes->set('auth_token', $token);
}
public function onKernelResponse(ResponseEvent $event): void
{
$token = $event->getRequest()->attributes->get('auth_token');
if (null === $token) {
return;
}
$response = $event->getResponse();
$response->headers->set(
'X-Content-Hash',
sha1($response->getContent().$token),
);
}
public static function getSubscribedEvents(): array
{
return [
KernelEvents::CONTROLLER => 'onKernelController',
KernelEvents::RESPONSE => 'onKernelResponse',
];
}
}
Points clés :
ControllerEventetResponseEventsont type-hintés sur les méthodes callbacks — leur typage est la source de vérité.- L'état (
auth_token) transite viaRequest::$attributes, pas via une propriété d'instance. - L'interface marqueur
TokenAuthenticatedControllerest portée par les contrôleurs concernés — le subscriber ne s'active que pour eux. #[Autowire(param: 'app.api_tokens')]injecte un paramètre container plutôt qu'un service (cf./symfony:service-wire).
Pattern 2 : plusieurs callbacks ordonnés sur un même événement
Cas d'usage : gérer les exceptions en plusieurs étapes distinctes (log brut, transformation en JSON, notification ops) qui doivent s'exécuter dans un ordre précis.
// src/EventSubscriber/ExceptionHandlingSubscriber.php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Notification\OpsNotifier;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
final class ExceptionHandlingSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly OpsNotifier $ops,
) {}
public static function getSubscribedEvents(): array
{
return [
ExceptionEvent::class => [
['logException', 100],
['transformToJson', 0],
['notifyOps', -100],
],
];
}
public function logException(ExceptionEvent $event): void
{
$this->logger->error(
$event->getThrowable()->getMessage(),
['exception' => $event->getThrowable()],
);
}
public function transformToJson(ExceptionEvent $event): void
{
$throwable = $event->getThrowable();
$event->setResponse(new JsonResponse(
['error' => $throwable->getMessage()],
500,
));
}
public function notifyOps(ExceptionEvent $event): void
{
// tourne après transformToJson ; on notifie uniquement si toujours 5xx
$response = $event->getResponse();
if ($response !== null && $response->getStatusCode() >= 500) {
$this->ops->notify($event->getThrowable());
}
}
}
Logique : logException (priorité 100) capture d'abord l'exception brute, transformToJson (0) produit la réponse, notifyOps (-100) exploite la réponse désormais disponible.
Pattern 3 : subscriber sur un événement custom
Cas d'usage : un mailer applicatif dispatch un AfterMailSentEvent, un subscriber archive chaque envoi. Voir /symfony:event-dispatch pour la création de l'événement et le dispatch.
// src/EventSubscriber/ArchiveSentMailSubscriber.php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Event\AfterMailSentEvent;
use App\Mail\MailArchive;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class ArchiveSentMailSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly MailArchive $archive,
) {}
public static function getSubscribedEvents(): array
{
return [
AfterMailSentEvent::class => 'onAfterMailSent',
];
}
public function onAfterMailSent(AfterMailSentEvent $event): void
{
$this->archive->store(
$event->getMessageId(),
$event->getRecipient(),
$event->getSubject(),
);
}
}
Aucune config : l'autoconfigure tague, le dispatcher appelle.
Événements du kernel les plus utiles
| Constante / classe | Moment | Usage typique |
|---|---|---|
KernelEvents::REQUEST / RequestEvent |
Avant résolution du contrôleur | Routing applicatif, courts-circuit (setResponse) |
KernelEvents::CONTROLLER / ControllerEvent |
Contrôleur résolu, avant appel | Vérifs sur le contrôleur, enrichir la requête |
KernelEvents::CONTROLLER_ARGUMENTS / ControllerArgumentsEvent |
Arguments résolus, avant appel | Modifier les arguments passés au contrôleur |
KernelEvents::VIEW / ViewEvent |
Contrôleur a renvoyé autre chose qu'une Response |
Transformer en Response (ex : sérialisation auto) |
KernelEvents::RESPONSE / ResponseEvent |
Juste avant l'envoi de la réponse | Headers, cookies, CSP, signing |
KernelEvents::FINISH_REQUEST / FinishRequestEvent |
Après envoi de la réponse principale | Cleanup, reset de locale |
KernelEvents::TERMINATE / TerminateEvent |
Après réponse envoyée au client | Tâches lourdes non bloquantes |
KernelEvents::EXCEPTION / ExceptionEvent |
Exception non capturée | Transformation en Response |
ConsoleEvents::COMMAND, TERMINATE, ERROR |
CLI | Instrumentation des commandes |
Vérifier l'abonnement
# tous les subscribers + listeners, ordre d'exécution
symfony console debug:event-dispatcher
# un événement spécifique (FQCN ou string)
symfony console debug:event-dispatcher "Symfony\Component\HttpKernel\Event\ExceptionEvent"
symfony console debug:event-dispatcher kernel.exception
# matching partiel
symfony console debug:event-dispatcher kernel
# dispatcher non-global (ex : firewall security)
symfony console debug:event-dispatcher --dispatcher=security.event_dispatcher.main
La sortie montre chaque callback avec sa priorité et son service d'origine — la référence pour vérifier que l'abonnement est pris en compte et inséré au bon endroit.
Tests
Test unitaire (pur PHP, sans container)
declare(strict_types=1);
namespace App\Tests\EventSubscriber;
use App\Event\AfterMailSentEvent;
use App\EventSubscriber\ArchiveSentMailSubscriber;
use App\Mail\MailArchive;
use PHPUnit\Framework\TestCase;
final class ArchiveSentMailSubscriberTest extends TestCase
{
public function test_it_archives_sent_mail(): void
{
$archive = $this->createMock(MailArchive::class);
$archive->expects(self::once())
->method('store')
->with('msg-42', 'alice@example.com', 'Welcome');
$subscriber = new ArchiveSentMailSubscriber($archive);
$subscriber->onAfterMailSent(
new AfterMailSentEvent('msg-42', 'alice@example.com', 'Welcome'),
);
}
public function test_it_subscribes_to_after_mail_sent(): void
{
self::assertArrayHasKey(
AfterMailSentEvent::class,
ArchiveSentMailSubscriber::getSubscribedEvents(),
);
}
}
Test d'intégration via EventDispatcher
$dispatcher = new \Symfony\Component\EventDispatcher\EventDispatcher();
$dispatcher->addSubscriber(new ArchiveSentMailSubscriber($archive));
$dispatcher->dispatch(new AfterMailSentEvent('msg-42', 'alice@example.com', 'Welcome'));
Plus fidèle si on veut aussi tester l'ordre entre plusieurs subscribers.
Pièges fréquents
- Constante
KernelEvents::RESPONSEvs classeResponseEvent: les deux fonctionnent. PréférerFQCN::classpour la cohérence, utiliser la constante uniquement si elle rend l'intention plus lisible (ex :KernelEvents::TERMINATEsans classe dédiée attendue). - Subscriber non taggué : si
autoconfigure: falsequelque part ou si la classe est dans un dossier exclu (src/Kernel.php,src/Entity/), le tag manque. Vérifier avecdebug:container App\\EventSubscriber\\NomSubscriber→ champTagsdoit contenirkernel.event_subscriber. - Exception dans un subscriber
kernel.response: remonte et casse la réponse. Toujours encadrer les effets de bord risqués (appel HTTP externe, I/O) par un try/catch qui log sans propager, ou déplacer versKernelEvents::TERMINATE. - Priorité choisie à l'aveugle : les écouteurs internes Symfony (firewall, routing, profiler) utilisent des priorités précises. Avant d'insérer avec
priority: 10, lancerdebug:event-dispatcher <event>pour voir où on tombe. - Subscriber qui mute l'événement puis appelle
stopPropagation(): légitime mais à documenter — les subscribers suivants ne verront jamais l'événement. Rare. Préférer laisser la chaîne continuer. - Même subscriber abonné au même événement deux fois dans
getSubscribedEvents(): Symfony silencieusement exécute les deux callbacks. Erreur de copier-coller typique — vérifier après refactor.
Déroulement
1 — Cadrer
- Combien d'événements ? Un seul → candidate pour
/symfony:event-listen(plus léger). Plusieurs liés → subscriber. - Événement kernel existant ou custom ? Custom → s'assurer qu'il est dispatché (
/symfony:event-dispatch). - Ordre d'exécution contraint ? Fixer les priorités dès le départ.
2 — Implémenter
- Classe sous
src/EventSubscriber/<Nom>Subscriber.php,final,declare(strict_types=1). implements EventSubscriberInterface,getSubscribedEvents(): arraystatique.- Une méthode callback par abonnement, typehintée sur la classe d'événement.
- Dépendances injectées par constructeur (jamais d'état mutable entre appels).
3 — Vérifier
symfony console debug:event-dispatcher <Event>::class
symfony console debug:container App\\EventSubscriber\\NomSubscriber
symfony console lint:container
vendor/bin/phpstan analyse src
Test unitaire de chaque callback + test que getSubscribedEvents() renvoie bien la map attendue.
4 — Clôture
Afficher :
- Fichier créé + dossier.
- Événements abonnés (sortie
debug:event-dispatcher). - Priorité(s) choisie(s) et justification (voisins dans la chaîne).
- Ce qui reste : tests, éventuellement
/symfony:event-dispatchsi événement custom à créer,/symfony:event-listensi on veut finalement un écouteur mono-événement.
Delta Sylius
- Sylius dispatche beaucoup d'événements génériques (
GenericEvent) autour des resources (sylius.product.pre_create,sylius.order.post_update, etc.). Pour s'y abonner, la signature du callback reçoitSymfony\Component\EventDispatcher\GenericEventet on récupère la resource via$event->getSubject().
use Symfony\Component\EventDispatcher\GenericEvent;
public function onProductPostCreate(GenericEvent $event): void
{
/** @var \Sylius\Component\Core\Model\ProductInterface $product */
$product = $event->getSubject();
// ...
}
public static function getSubscribedEvents(): array
{
return [
'sylius.product.post_create' => 'onProductPostCreate',
];
}
- Sylius utilise toujours les noms string (
sylius.<resource>.<hook>), pas un FQCN. C'est l'exception à la règle FQCN — imposée par le bus Sylius. - Les hooks disponibles (
pre_create,post_create,initialize_*,pre_update,post_update,pre_delete,post_delete) viennent deSylius\Bundle\ResourceBundle\Controller\ResourceController. Consultergrep -rn "eventDispatcher->dispatch" vendor/sylius/resource-bundle/pour la liste exhaustive.
Argument optionnel
/symfony:event-subscribe OrderCreatedEvent — crée OrderCreatedSubscriber abonné à l'événement nommé, avec un callback stub.
/symfony:event-subscribe "kernel.response + kernel.controller pour signer les réponses API" — monte un subscriber multi-événements (pattern before/after filter).
/symfony:event-subscribe src/EventSubscriber/FooSubscriber.php — audit d'un subscriber existant (interface implémentée, getSubscribedEvents() cohérent, priorités documentées, callbacks courts).
/symfony:event-subscribe sans argument — demande l'événement (ou la liste) cible et la logique métier.