• 12 heures
  • Difficile

Ce cours est visible gratuitement en ligne.

course.header.alt.is_certifying

J'ai tout compris !

Mis à jour le 26/08/2024

Comprenez le cycle de vie de Symfony avec l’EventDispatcher

Découvrez l’EventDispatcher

Vous connaissez maintenant l’essentiel pour faire fonctionner une application simple avec Symfony. Amélie et la médiathèque ont une application prête, grâce à laquelle elles vont pouvoir diffuser leur catalogue. Néanmoins, Symfony recèle encore bien des secrets, et surtout son fonctionnement reste probablement un peu opaque à vos yeux. Que faire alors si un bug survient, ou si Amélie a besoin d’une nouvelle fonctionnalité plus complexe ?

Nous allons donc maintenant discuter d’un composant essentiel, qui est imbriqué dans toutes les couches de Symfony, et qui va vous permettre de mieux comprendre le fonctionnement du framework. De plus, vous pourrez grâce à ce composant vous “brancher” sur la mécanique interne de Symfony pour la personnaliser dans une certaine mesure et y ajouter vos propres actions.

Ce composant, c’est l’EventDispatcher. Le but de ce composant est de vous permettre de découpler votre application. 

Découpler ? Qu’est-ce que ça veut dire ?

Cela signifie que nous allons essayer de réduire les dépendances directes entre toutes les parties de notre application. Concrètement, il vous permet de dispatcher des objets appelés Events, ou évènements. Ces events peuvent ensuite être écoutés par des EventListeners ou des EventSubscribers qui auront au préalable indiqué quel événement ils écoutent, et qui pourront alors réagir à leur dispatch.

Schéma d'interactions avec l'EventDispatcher
  1. Les Listeners et Subscribers sont enregistrés auprès de l'EventDispatcherInterface grâce à la méthode addEventListener pour un Event spécifique.

  2. Une classe crée un objet Event contenant un objet ou une donnée et le passe à la méthode dispatch de l'EventDispatcherInterface.

  3. L'EventDispatcher appelle chaque Listener qui s'est enregistré pour cet Event.

Si vous avez déjà fait un peu de JavaScript, ça doit vous sembler familier. Lorsque vous souhaitez savoir quand un bouton a été cliqué, vous attachez un EventListener sur ce bouton, pour écouter l’évènement  clicked  . C’est la même chose ici.

D’accord, mais réagir à un clic ça a un sens. Pourquoi est-ce que je devrais réagir à des trucs ici ?

C’est ce que nous allons voir de suite.

Comprenez l’intérêt des Events dans Symfony

Imaginez une situation concrète : Vous avez mis en place une solution de e-commerce complète avec un système de commande. Une fois que le client valide son panier, plusieurs choses doivent se passer :

  1. Il faut transformer le panier en commande, et vous aurez certainement une classe dédiée pour ça car nous découpons notre code pour faciliter sa maintenance.

  2. Il faudra certainement contacter un organisme de paiement, encore une fois il nous faudra une autre classe.

  3. Il faudra aussi notifier un entrepôt pour que la commande soit préparée et envoyée ; généralement leurs systèmes de gestion sont sur des applications bien séparées.

  4. Nous allons aussi certainement envoyer un e-mail de confirmation au client, ce qui se fait à l’aide d‘une classe spécifique.

  5.  Enfin, il faudra générer une facture à partir de cette commande.

Si nous devions écrire un controller dans lequel nous demandons tout cela, il ressemblerait à quelque chose dans ce genre :

<?php
// …
#[Route('/order/confirm', name: 'app_order_confirm', methods: ['POST'])]
public function orderConfirm(
    Cart $cart,
    CartToOrderTransformer $transformer,
    PaymentHandler $handler,
    WarehouseNotifier $notifier,
    MailerInterface $mailer,
    InvoiceGenerator $generator,
) {
// ...

Bien entendu, ces dépendances sont fictives et ne sont là que pour l’exemple, mais nous en avons un très grand nombre, et nous n’avons encore écrit aucune logique pour faire ce que nous souhaitons faire.

La première chose à savoir dans ces cas-là c'est que toutes ces dépendances, et la logique qu’elles permettent de créer, n’ont rien à faire dans un controller.

Mais… pourquoi tout est dans le controller dans l’exemple, alors ?

Parce que c’est ce que tout le monde fait instinctivement. Mais rappelez-vous une des premières choses que je vous ai dites sur les controllers par le biais de Sébastien :

Un controller est un callable qui renvoie une réponse.

Il faudrait donc trouver un moyen de mettre toute cette logique ailleurs. Techniquement rien ne vous empêche de créer une autre classe, et d’injecter vos dépendances dedans. On injecterait alors cette classe-ci dans le controller uniquement, comme ceci :

<?php
// src/Order/OrderHandler.php
class OrderHandler
{
    public function __construct(
        private readonly CartToOrderTransformer $transformer,
        private readonly PaymentHandler $handler,
        private readonly WarehouseNotifier $notifier,
        private readonly MailerInterface $mailer,
        private readonly InvoiceGenerator $generator,
    )
    {
    }
    
    public function confirmOrder(Cart $cart): void
    {
    // ...

Puis, cette classe :

<?php
// src/Controller/OrderController.php
#[Route('/order/confirm', name: 'app_order_confirm', methods: ['POST'])]
public function orderConfirm(
    Cart $cart,
    OrderHandler $handler,
) {
    $handler->confirmOrder($cart);
    // ...

C’est une solution valable. Mais en y réfléchissant un peu plus, on s’aperçoit vite qu’elle est complexe à maintenir sur le long terme : nous allons devoir mettre toute la logique de nos actions successives (transformer le panier en commande, gérer le paiement, etc.) à un seul endroit. Si une seule de ces classes change pour une raison ou une autre, tout notre  OrderHandler  risque d’en être perturbé, et nous risquons de casser entièrement l’action de confirmation.

Une autre possibilité des événements : Imaginons que vous souhaitiez faire une action ou des vérifications avant même que le routing ait lieu. Il existe un événement dans Symfony qui vous le permet. Vous allez donc pouvoir l’écouter, et faire vos vérifications comme bon vous semble.

Découvrez les EventListeners et EventSubscribers

Pour pouvoir écouter des événements et y réagir, nous allons avoir besoin d’EventListeners et/ou d’EventSubscribers. Il est donc temps de leur donner une définition un peu plus précise.

C’est la première fois qu’on parle d’EventSubscriber ! C’est quoi ce nouveau truc ?

Je vais y venir !

En premier lieu, un EventListener (ou plus simplement Listener) est avant tout un objet appelable par PHP, un callable, comme une fonction, une méthode au sein d’une classe, ou une classe disposant d’une méthode magique  __invoke  par exemple, comme nous l’avons vu lors du chapitre sur les controllers.

Le principe est le suivant : Nous écrivons notre callable, nous l’enregistrons auprès du composant EventDispatcher en lui indiquant quel événement ce Listener écoute, et lorsque l’événement est dispatché, l’EventDispatcher appelle notre Listener en lui passant l’objet d’événement.

Pour enregistrer un EventListener auprès du dispatcher, il suffit d’ajouter un attribut  AsEventListener  contenant le nom de l’event qu’on souhaite écouter au-dessus de la méthode qui devra être exécutée lorsque cet event sera dispatché :

<?php
final class ExceptionListener
{
    // Cet attribut permet d'enregistrer le listener auprès du dispatcher
    #[AsEventListener(event: KernelEvents::EXCEPTION)]
    public function onKernelException(ExceptionEvent $event): void
    {
        // ici on met ce qu’on veut faire quand cet event est dispatché
    }
}

Un EventSubscriber, quant à lui, est simplement une classe qui peut contenir plusieurs Listeners et va les enregistrer automatiquement auprès de l’EventDispatcher. Cette classe a simplement à implémenter l’interface  Symfony\Component\EventDispatcher\EventDispatcherInterface  ; Symfony la reconnaîtra comme un EventSubscriber et fera le nécessaire pour enregistrer les listeners qu’elle contient :

<?php
class ExceptionSubscriber implements EventSubscriberInterface
{
    public function onKernelException(ExceptionEvent $event): void
    {
        // ici on met ce qu’on veut faire quand cet event est dispatché
    }
    
    public static function getSubscribedEvents(): array
    {
        return [
            // ici on indique: nom de l'event => méthode à exécuter
            KernelEvents::EXCEPTION => 'onKernelException',
        ];
    }
}

En plus de tout cela, les listeners ont une priorité, qui définit l’ordre dans lequel ils seront appelés. Quand un événement est dispatché, les listeners qui l’écoutent sont triés de la priorité la plus haute vers la plus basse. Un listener avec une priorité haute sera donc appelé avant les listeners de priorité inférieure qui écoutent le même event.

Pour compléter cela, les événements gèrent la notion de propagation. Cela signifie que si un listener qui avait une priorité élevée stoppe la propagation de l’événement qu’il écoute (en appelant tout simplement  $event->stopPropagation()  ), aucun des listeners suivants ne sera appelé.

Pour donner une priorité à un EventListener, spécifiez-la dans l’attribut  AsEventListener  :

<?php
final class ExceptionListener
{
    #[AsEventListener(event: KernelEvents::EXCEPTION, priority: 10)]
    public function onKernelException(ExceptionEvent $event): void
    // …

Dans un EventSubscriber, remplacez le nom de la méthode à exécuter par un array contenant ce même nom de méthode et la priorité :

<?php
class ExceptionSubscriber implements EventSubscriberInterface
{
    // …
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::EXCEPTION => ['onKernelException', 10],
        ];
    }

D’accord, mais comment je peux savoir quels événements je peux écouter, et à quoi chaque événement sert ?

Il n’y a pas de méthode miracle, la plupart du temps la documentation de Symfony sera votre meilleure alliée. Généralement, quand un composant dispatche des événements, c’est précisé dans sa documentation. Une autre bonne méthode est de demander à la console Symfony. Il existe une commande qui vous permet de lister tous les EventListeners enregistrés auprès de l’EventDispatcher.

Il s’agit de la commande suivante :

symfony console debug:event-dispatcher 

Mais rassurez-vous, nous allons déjà vous présenter les principaux événements de Symfony : les events du Kernel.

Découvrez les principaux events du Kernel Symfony

Déjà, un petit rappel s’impose peut-être. Le Kernel de Symfony est l’objet qui se charge de traiter la requête. C'est-à-dire que c’est lui qui est appelé pour faire la transformation de la requête en réponse. C’est donc lui qui récupère le résultat du routing, appelle le controller correspondant à la route, récupère la réponse, et la retourne.

Pendant qu’il fait tout cela, il dispatche à certains moments clés des événements sur lesquels vous pouvez vous brancher pour personnaliser le fonctionnement de Symfony. Pour indiquer au dispatcher que vous écoutez ces événements, vous pouvez lui donner le nom de l’événement sous forme de chaîne de caractères arbitraire, ou avec le nom de leur classe. Ces événements sont les suivants :

Nom de l’event

Classe

Quand ?

kernel.request

RequestEvent

Quand le Kernel commence à traiter la requête. Le routing est un Listener qui a lieu pendant cet événement. Permet de renvoyer une réponse anticipée en stoppant la propagation en cas de besoin.

kernel.controller

ControllerEvent

Quand le routing a identifié la route et le controller associé.

kernel.controller_arguments

ControllerArgumentsEvents

Quand le routing a vérifié que le controller existe et identifié les arguments qu’il demande.

kernel.view

ViewEvent

Quand le controller a été exécuté, seulement s'il ne retourne pas d’objet Response.

kernel.response

ResponseEvent

Quand le controller a été exécuté et qu’on a un objet Response, après  kernel.view (s'il a été dispatché), pour vous permettre de modifier l’objet Response.

kernel.finish_request

FinishRequestEvent

Après  kernel.response  , pour vous permettre d’inspecter l’objet Request une dernière fois.

kernel.terminate

TerminateEvent

Après que la réponse a été renvoyée au client, pour vous permettre de faire des traitements longs sans pénaliser l’utilisateur.

kernel.exception

ExceptionEvent

N’importe quand pendant que le Kernel traite la requête, si une exception est levée mais que vous ne l’attrapez pas. On reprend ensuite le flux normal depuis  kernel.response  pour renvoyer notre exception sous forme de message d’erreur.

Allez, une version bien dessinée :

Principaux événement du Kernel Symfony
Principaux événement du Kernel Symfony

Utiliser les événements du Kernel

  • L'événement  kernel.request  permet de renvoyer une réponse anticipée.

  • On peut injecter tout ce dont on a besoin par la fonction  __construct  de toutes les classes qui se trouvent dans le dossier  src  .

Pour reprendre notre exemple initial de validation de panier, on aurait pu du coup profiter d’un événement, par exemple  kernel.terminate  . Cet événement étant dispatché après que la réponse a été renvoyée au client, nous ne risquons donc pas de bloquer l’affichage de la page.

Par contre, sa classe d’Event,  TerminateEvent  , contient l’objet Request de la requête qui vient d’être traité, ainsi que l’objet Response qui vient d’être renvoyé. Nous aurions donc pu créer un EventListener – ou même plusieurs ! – pour vérifier que la réponse que nous venons d’envoyer est une validation de commande, puis en profiter pour effectuer tous les traitements lourds prévus, comme la génération de facture, l’envoi de la commande à l’entrepôt, etc.

À vous de jouer

Contexte

Afin d'obtenir une traçabilité minimale de ce qu'il se passe sur l'application, Amélie vous contacte avec une dernière demande :

Bonjour, comme nous avons souvent des intérimaires, je devrai leur créer des comptes, mais je me connais, je risque d'oublier de les désactiver. Est-ce que vous pourriez enregistrer la date de la dernière connexion de chaque utilisateur, comme ça de temps en temps je ferai le ménage et je désactiverai tous ceux qui ne se sont pas connectés régulièrement ? 

Vous demandez conseil à Sébastien, qui vous indique d'utiliser un EventListener pour ça. Il ajoute que l'event  security.interactive_login  est justement déclenché à chaque fois qu'un utilisateur se connecte volontairement.

Consigne

Il ne vous reste plus qu'à faire deux choses :

  • Modifier votre entité User pour ajouter une propriété  lastConnectedAt  , qui sera un  date_immutable  nullable (n'oubliez pas de créer une migration et de l'exécuter ensuite).

  • Créer un EventListener pour enregistrer la date du jour dès qu'un utilisateur se connecte.

En résumé

  • L’EventDispatcher permet de dispatcher et d’écouter des événements pour découpler notre code.

  • De nombreux composants Symfony dispatchent leurs propres événements, dont le Kernel.

  • Les événements du Kernel permettent de se brancher sur le fonctionnement interne de Symfony pour en personnaliser l’exécution.

  • Pour écouter un événement, on peut créer des EventListeners ou des EventsSubscribers.

Et maintenant, voyons comment faciliter les tests avec des données toutes prêtes ! 

Exemple de certificat de réussite
Exemple de certificat de réussite