Skip to content

7. Le cycle de vie d'une requête

Entre l'arrivée d'une requête et le départ de sa réponse, le noyau de [Symfony] (la classe [HttpKernel]) enchaîne plusieurs étapes, et, à chacune d'elles, il publie un événement auquel l'application peut réagir par un écouteur. Nous en avons déjà rencontré plusieurs : les écouteurs de [kernel.request] et [kernel.response] (exemples 03, 16, 22), de [kernel.exception] (exemples 08 et 12), les résolveurs d'arguments (exemple 12). Ce chapitre les réunit dans un seul exemple, qui affiche dans la console un message à chaque étape, pour en observer l'ordre :

  • 1 et 2. *[kernel.request] : la requête vient d'arriver. Tous les écouteurs de cet événement sont appelés, par priorité décroissante. L'un d'eux est le *routage de [Symfony] (l'écouteur [RouterListener], de priorité 32) : il cherche la route qui correspond à l'URL et à la méthode, et range son nom et ses paramètres dans les attributs de la requête ; si aucune ne correspond, il lève une exception [NotFoundHttpException] (404). Un écouteur de priorité supérieure à 32 (1) s'exécute avant le routage, et ne connaît pas la route ; un écouteur de priorité inférieure (2) la connaît. Un écouteur de [kernel.request] peut aussi fournir lui-même la réponse ([$evenement->setResponse(...)]) : l'action n'est alors pas appelée ;
  • 3. *[kernel.controller]* : l'action a été choisie, mais pas encore appelée. Un écouteur peut examiner l'action (ses attributs [PHP], par exemple), ou la remplacer ;
  • 4. Les résolveurs d'arguments calculent les paramètres de l'action (exemple 12), puis l'événement [kernel.controller_arguments] est publié ;
  • 5. L'action, et la vue qu'elle fait fabriquer. (Si l'action ne rend pas un objet [Response], un événement de plus, [kernel.view], permet à un écouteur de fabriquer la réponse à partir de ce qu'elle a rendu.) ;
  • 6 et 7. *[kernel.response]*** : la réponse est prête. Chaque écouteur peut la modifier (ajouter un entête, un cookie), par priorité décroissante ;
  • 8. *[kernel.terminate], publié *après l'envoi de la réponse au navigateur (c'est l'appel [$noyau->terminate(...)] du fichier [public/index.php], exemple 01). Les écouteurs ne peuvent plus changer la réponse : ils servent aux traitements qui peuvent attendre (écrire un journal, envoyer un courriel...), sans faire patienter l'utilisateur ;
  • x. *[kernel.exception] : une exception levée à n'importe quelle étape (par un écouteur, un résolveur, l'action ou la vue) interrompt le traitement ; elle est confiée aux écouteurs de cet événement. Le premier qui fournit une réponse arrête la recherche. Cette réponse passe ensuite par [kernel.response]*, comme une réponse ordinaire. Si aucun écouteur de l'application n'en fournit, c'est [Symfony] qui fabrique la page d'erreur : détaillée en mode debug, sobre sinon.

C'est l'équivalent des filtres de [Java] et des intercepteurs de [Spring MVC], des filtres d'action d'[ASP.NET Core MVC], des fonctions [before_request] / [after_request] de [Flask], et des middlewares de [Laravel]. La différence avec ces derniers est de structure : un middleware enveloppe la suite du traitement (il appelle [$next], et voit donc la requête avant et la réponse après, dans le même code), alors qu'un écouteur réagit à un événement ; pour agir avant et après l'action, il faut écouter deux événements. En contrepartie, un écouteur peut intervenir à une étape précise (après le routage, après le choix de l'action...), et l'ordre des écouteurs est donné par un nombre, la priorité.

7.1. Exemple 24 : les étapes du traitement d'une requête

24-cycle-de-vie/
├── src/
│   ├── Controller/
│   │   └── CalculController.php
│   ├── EventListener/
│   │   ├── Bloquer.php
│   │   ├── Chrono.php
│   │   ├── Chronometre.php
│   │   ├── Journal.php
│   │   └── PageErreur.php
│   └── Liaison/
│       └── EntierResolver.php
├── templates/
│   ├── calcul/
│   │   └── resultat.html.twig
│   ├── erreur.html.twig
│   └── layout.html.twig
└── ...

L'application n'a qu'une action, qui calcule le carré d'un entier : [/carre/7]. Selon l'URL, elle réussit, ou elle échoue de différentes façons. Les messages sont écrits dans la console du serveur par la fonction [error_log] de [PHP], que le serveur de développement ([php -S]) affiche.

7.1.1. Avant et après tout le reste : src/EventListener/Journal.php

<?php
// src/EventListener/Journal.php : un écouteur de DEUX événements. Il voit passer
// chaque requête AVANT le reste de l'application (même le routage), et la réponse
// APRÈS tout le reste

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class Journal
{
    // priorité 100 : avant le routage de Symfony (priorité 32)
    #[AsEventListener(event: KernelEvents::REQUEST, priority: 100)]
    public function avant(RequestEvent $evenement): void
    {
        $requete = $evenement->getRequest();
        // error_log : un message dans la console du serveur de développement
        error_log('--- '.$requete->getMethod().' '.$requete->getRequestUri());
        error_log('1. Journal, kernel.request (avant le routage)');
    }

    // priorité -100 : après les autres écouteurs de kernel.response
    #[AsEventListener(event: KernelEvents::RESPONSE, priority: -100)]
    public function apres(ResponseEvent $evenement): void
    {
        error_log('7. Journal, kernel.response (code '.$evenement->getResponse()->getStatusCode().')');
    }
}

Une même classe peut écouter plusieurs événements : l'attribut [#[AsEventListener]] se place alors sur chacune des méthodes concernées.

  • lignes 16-23 : le message n° 1, avec la requête ([getRequestUri()] : le chemin et les paramètres). La priorité 100 place l'écouteur avant le routage : rien ne s'est encore passé dans [Symfony] ;
  • lignes 26-30 : le message n° 7, quand tout le reste a été fait. La priorité -100 place l'écouteur après ceux de l'application et de [Symfony] (qui, pour la plupart, ont une priorité comprise entre -10 et 10). [getStatusCode()] est le code de la réponse.

Un tel écouteur ne connaît pas la route choisie, puisque le routage n'a pas encore eu lieu. On l'utilise pour des traitements qui concernent toutes les requêtes : journalisation, prise en compte d'un proxy (l'étude de cas en parle, à propos de l'adresse IP des clients)...

7.1.2. Après le routage : src/EventListener/Bloquer.php

<?php
// src/EventListener/Bloquer.php : un écouteur de kernel.request, exécuté APRÈS le
// routage : il connaît la route choisie

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\KernelEvents;

// priorité 0 (par défaut) : après le routage (priorité 32)
#[AsEventListener(event: KernelEvents::REQUEST)]
class Bloquer
{
    public function __invoke(RequestEvent $evenement): void
    {
        $requete = $evenement->getRequest();
        // l'attribut _route de la requête : le nom de la route choisie par le routage
        error_log('2. Bloquer, kernel.request (route : '.$requete->attributes->get('_route').')');
        if ($requete->query->has('bloquer')) {
            // une exception : la suite (Chrono, l'action) n'est pas exécutée
            throw new AccessDeniedHttpException('accès bloqué');
        }
    }
}
  • ligne 13 : la priorité par défaut, 0 : l'écouteur est appelé après le routage ;
  • ligne 20 : message n° 2. Le routage a rangé le nom de la route choisie dans l'attribut [_route] de la requête (et ses paramètres dans les attributs de même nom, exemple 12) ;
  • lignes 21-24 : le paramètre [?bloquer] de l'URL ([has] teste sa présence) provoque une erreur 403 (Forbidden, accès refusé) : l'exception [AccessDeniedHttpException] interrompt le traitement.

7.1.3. Avant l'action : src/EventListener/Chronometre.php et Chrono.php

Nous voulons chronométrer certaines actions seulement, celles qui le demandent. Il suffit de les marquer d'un attribut [PHP] de notre invention :

<?php
// src/EventListener/Chronometre.php : un ATTRIBUT PHP de notre invention, à placer
// sur les actions dont on veut mesurer la durée (cf. l'écouteur Chrono)

namespace App\EventListener;

use Attribute;

// #[Attribute] : cette classe est un attribut ; TARGET_METHOD : il se place sur une méthode
#[Attribute(Attribute::TARGET_METHOD)]
class Chronometre
{
}

Un attribut est une classe ordinaire, elle-même marquée de l'attribut [#[Attribute]] ; [Attribute::TARGET_METHOD] indique qu'il se place sur une méthode. Celui-ci ne porte aucune information : sa seule présence compte.

<?php
// src/EventListener/Chrono.php : un écouteur de trois événements, qui n'agit que
// pour les actions marquées de l'attribut #[Chronometre] : AVANT l'action, APRÈS
// elle, puis une dernière fois APRÈS l'envoi de la réponse (kernel.terminate)

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class Chrono
{
    // kernel.controller : l'action a été choisie, elle n'est pas encore appelée
    #[AsEventListener(event: KernelEvents::CONTROLLER)]
    public function avant(ControllerEvent $evenement): void
    {
        // getAttributes : les attributs PHP d'une classe donnée, portés par l'action
        if ($evenement->getAttributes(Chronometre::class) === []) {
            return;
        }
        error_log('3. Chrono, kernel.controller (avant l\'action)');
        // l'heure de départ (hrtime : une horloge précise, en nanosecondes), rangée
        // dans les attributs de la requête (portée requête, cf. exemple 12)
        $evenement->getRequest()->attributes->set('debut', hrtime(true));
    }

    #[AsEventListener(event: KernelEvents::RESPONSE)]
    public function apres(ResponseEvent $evenement): void
    {
        if ($evenement->getRequest()->attributes->has('debut')) {
            error_log('6. Chrono, kernel.response (code '.$evenement->getResponse()->getStatusCode().')');
        }
    }

    // kernel.terminate : à la toute fin, APRÈS l'envoi de la réponse au navigateur ;
    // on ne peut plus changer la réponse
    #[AsEventListener(event: KernelEvents::TERMINATE)]
    public function fin(TerminateEvent $evenement): void
    {
        $debut = $evenement->getRequest()->attributes->get('debut');
        // la mesure n'a pas commencé si l'action n'a pas été atteinte
        if ($debut !== null) {
            $ms = round((hrtime(true) - $debut) / 1e6);
            error_log("8. Chrono, kernel.terminate (fin, $ms ms)");
        }
    }
}
  • lignes 17-28 : l'événement [kernel.controller]. La méthode [getAttributes] de l'événement rend les attributs [PHP] d'une classe donnée portés par l'action (et par sa classe) : une liste vide signifie que l'action n'est pas marquée, et l'écouteur ne fait rien. Sinon, message n° 3, et l'heure de départ, rangée dans les attributs de la requête (exemple 12). [hrtime(true)] est une horloge précise, en nanosecondes, faite pour mesurer des durées ;
  • lignes 30-36 : message n° 6, sur [kernel.response], avec le code de la réponse, si la mesure a commencé. La priorité par défaut, 0, place cet écouteur avant celui du [Journal] (-100) ;
  • lignes 40-49 : l'événement [kernel.terminate], publié après l'envoi de la réponse : message n° 8, avec la durée écoulée depuis le message n° 3. Le test de la ligne 45 vérifie que la mesure a commencé : l'événement est publié pour toutes les requêtes, même celles qui n'ont pas atteint l'action.

C'est ainsi que [Symfony] lui-même réalise certains de ses attributs : [#[IsGranted]], que nous verrons au chapitre suivant, est lu par un écouteur de [kernel.controller_arguments], qui refuse l'accès à l'action si l'utilisateur n'a pas le droit demandé.

7.1.4. Les arguments de l'action : src/Liaison/EntierResolver.php et le contrôleur

<?php
// src/Liaison/EntierResolver.php : un résolveur d'argument (cf. exemple 12) : le
// paramètre de route transformé en entier, ou en null si ce n'est pas un nombre
// entier (c'est alors l'action qui décide)

namespace App\Liaison;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;

class EntierResolver implements ValueResolverInterface
{
    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        $valeur = $request->attributes->get($argument->getName());
        error_log("4. EntierResolver, les arguments de l'action (valeur reçue : \"$valeur\")");
        $n = filter_var($valeur, FILTER_VALIDATE_INT);
        return [$n === false ? null : $n];
    }
}

Un résolveur d'argument (exemple 12), qui écrit le message n° 4. Il accepte n'importe quel texte, et le convertit en entier, ou en [null] s'il n'en est pas un ([filter_var] rend [false]) : contrairement à une contrainte [requirements] (exemple 02), qui rendrait une page 404 pour [/carre/abc], c'est ici l'action qui décidera.

<?php
// src/Controller/CalculController.php : une action chronométrée

namespace App\Controller;

use App\EventListener\Chronometre;
use App\Liaison\EntierResolver;
use DomainException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\ValueResolver;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;

class CalculController extends AbstractController
{
    // $n : le paramètre de route {n}, fourni par notre résolveur EntierResolver ;
    // #[Chronometre] : l'écouteur Chrono mesure la durée de cette action
    #[Route('/carre/{n}', name: 'carre', methods: ['GET'])]
    #[Chronometre]
    public function carre(#[ValueResolver(EntierResolver::class)] ?int $n): Response
    {
        error_log("5. action carre($n)");
        if ($n === null) {
            throw new BadRequestHttpException('pas un entier');    // erreur 400 : cf. PageErreur
        }
        if ($n === 13) {
            throw new DomainException('13 porte malheur');    // une erreur « imprévue »
        }
        return $this->render('calcul/resultat.html.twig', ['n' => $n, 'carre' => $n * $n]);
    }
}
  • ligne 20 : l'action est marquée de notre attribut [#[Chronometre]] ;
  • ligne 21 : le paramètre [$n] est fourni par notre résolveur (attribut [#[ValueResolver]], exemple 12) ;
  • lignes 24-26 : un paramètre qui n'est pas un entier : erreur 400 (Bad Request, requête incorrecte), par l'exception [BadRequestHttpException] ;
  • lignes 27-29 : le nombre 13 provoque une erreur « imprévue ». [DomainException] est une classe d'exception de la bibliothèque standard de [PHP] ;
  • ligne 30 : le cas normal. La vue [calcul/resultat.html.twig] affiche simplement le résultat.

7.1.5. La gestion des erreurs : src/EventListener/PageErreur.php

<?php
// src/EventListener/PageErreur.php : un écouteur de kernel.exception, qui remplace
// les pages d'erreur de Symfony par la nôtre

namespace App\EventListener;

use DomainException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Twig\Environment;

// priorité -10 : après l'écouteur de Symfony qui écrit l'exception dans le journal (0),
// avant celui qui fabrique sa page d'erreur (-128)
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: -10)]
class PageErreur
{
    // Environment : le moteur Twig lui-même, pour fabriquer une page hors d'un contrôleur
    public function __construct(private Environment $twig)
    {
    }

    public function __invoke(ExceptionEvent $evenement): void
    {
        $e = $evenement->getThrowable();
        if ($e instanceof HttpExceptionInterface) {
            // les erreurs HTTP (403, page inconnue -> 404...) : HttpExceptionInterface
            // est l'interface de toutes les exceptions HTTP ; elles portent leur code
            error_log("x. PageErreur, kernel.exception (HTTP {$e->getStatusCode()})");
            $code = $e->getStatusCode();
        } elseif ($e instanceof DomainException) {
            // une exception "imprévue" d'un type donné : DomainException et ses sous-classes
            error_log("x. PageErreur, kernel.exception (DomainException : {$e->getMessage()})");
            $code = 500;
        } else {
            return;    // les autres : la page d'erreur de Symfony
        }
        // la réponse de l'événement remplace l'exception (les écouteurs suivants ne sont
        // pas appelés) ; elle passera ensuite par kernel.response, comme une réponse ordinaire
        $evenement->setResponse(new Response(
            $this->twig->render('erreur.html.twig', ['code' => $code]), $code));
    }
}
  • lignes 15-17 : l'écouteur de [kernel.exception], avec la priorité -10. [Symfony] installe deux écouteurs de cet événement : le premier, de priorité 0, écrit l'exception dans le journal ; le second, de priorité -128, fabrique la page d'erreur de [Symfony]. Le nôtre passe entre les deux : l'exception est journalisée, puis nous fournissons la page ;
  • ligne 21 : l'écouteur n'est pas un contrôleur, et n'a pas la méthode [render] d'[AbstractController] : il reçoit le moteur [Twig] lui-même (la classe [Twig\Environment]), dont la méthode [render] rend la page fabriquée, sous forme de chaîne ;
  • lignes 28-32 : toutes les erreurs HTTP : une URL sans route (404), une méthode refusée (405), les exceptions [AccessDeniedHttpException] (403) et [BadRequestHttpException] (400)... implémentent l'interface [HttpExceptionInterface], et portent leur code ([getStatusCode()]). Ce seul test remplace donc toutes les pages d'erreur de [Symfony] par la nôtre ;
  • lignes 33-36 : une exception de la classe [DomainException] (ou de ses sous-classes, cf. exemple 08) : une erreur « imprévue », transformée en page d'erreur « maison », avec le code 500 (Internal Server Error). Le message de l'exception est affiché dans la console, mais pas dans la page : il peut contenir des détails internes, qui ne regardent pas l'utilisateur ;
  • lignes 37-39 : les autres exceptions sont laissées à [Symfony] ;
  • lignes 42-43 : [setResponse] fournit la réponse, qui remplace l'exception, et arrête la propagation de l'événement : les écouteurs suivants (la page d'erreur de [Symfony]) ne sont pas appelés.

La page d'erreur reçoit le code d'erreur, et affiche un message adapté :

{% extends 'layout.html.twig' %}
{# la page d'erreur ; son modèle : le code d'erreur #}

{% block contenu %}
  <div class="alert alert-danger">
    <h1 class="h4">Erreur {{ code }}</h1>
    {# un tableau associatif, indexé par le code ; ?? : la valeur par défaut #}
    <p class="mb-0">{{ {400: 'Requête incorrecte.', 403: 'Accès refusé.', 404: 'Page introuvable.'}[code] ?? 'Erreur interne du serveur.' }}</p>
  </div>
{% endblock %}
  • ligne 8 : un tableau associatif écrit dans la vue ([{clé: valeur, ...}]), dont on prend l'élément d'indice [code] ; l'opérateur [??] fournit une valeur par défaut quand cet élément n'existe pas.

Il existe une autre façon, plus simple encore, de remplacer les pages d'erreur, sans écrire d'écouteur : une vue [templates/bundles/TwigBundle/Exception/error404.html.twig] remplace la page de l'erreur 404, et de même pour chaque code ([error.html.twig] pour tous les autres). Ces vues ne servent qu'en production, le mode debug gardant ses pages détaillées (on peut les prévisualiser en mode debug à l'adresse [/_error/404], si l'on a importé les routes [@FrameworkBundle/Resources/config/routing/errors.php]). Un écouteur, lui, permet un traitement quelconque (une réponse JSON, une redirection...), dans tous les modes.

7.1.6. Exécution

Le cas normal, [/carre/7] :

Image

La console du serveur montre les huit étapes, dans l'ordre (les dates que [php -S] place en tête des lignes ont été retirées) :

--- GET /carre/7
1. Journal, kernel.request (avant le routage)
[info] Matched route "carre".
2. Bloquer, kernel.request (route : carre)
3. Chrono, kernel.controller (avant l'action)
4. EntierResolver, les arguments de l'action (valeur reçue : "7")
5. action carre(7)
6. Chrono, kernel.response (code 200)
7. Journal, kernel.response (code 200)
8. Chrono, kernel.terminate (fin, 2 ms)
127.0.0.1:51612 [200]: GET /carre/7

La ligne [[info]] vient du routage de [Symfony] : c'est son journal (log). Sans bibliothèque de journalisation (la plus utilisée est [Monolog], installée par [composer require logger]), [Symfony] utilise un journal minimal, qui écrit dans la console. La dernière ligne est le journal du serveur de développement, écrit une fois la requête terminée. Un accès bloqué, [/carre/7?bloquer] :

Image

1
2
3
4
5
6
7
--- GET /carre/7?bloquer
1. Journal, kernel.request (avant le routage)
[info] Matched route "carre".
2. Bloquer, kernel.request (route : carre)
[error] Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException: "accès bloqué" at Bloquer.php line 23
x. PageErreur, kernel.exception (HTTP 403)
7. Journal, kernel.response (code 403)

L'écouteur [Bloquer] a levé une exception : l'action n'a pas été choisie, ni ses arguments calculés, ni elle-même appelée. L'exception a été journalisée par [Symfony], puis transformée en réponse par notre écouteur ; cette réponse est passée par [kernel.response]. L'événement [kernel.terminate] a été publié, mais [Chrono] n'a rien affiché, la mesure n'ayant pas commencé. Un paramètre qui n'est pas un entier, [/carre/abc] :

Image

--- GET /carre/abc
1. Journal, kernel.request (avant le routage)
[info] Matched route "carre".
2. Bloquer, kernel.request (route : carre)
3. Chrono, kernel.controller (avant l'action)
4. EntierResolver, les arguments de l'action (valeur reçue : "abc")
5. action carre()
[error] Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\BadRequestHttpException: "pas un entier" at CalculController.php line 25
x. PageErreur, kernel.exception (HTTP 400)
6. Chrono, kernel.response (code 400)
7. Journal, kernel.response (code 400)
8. Chrono, kernel.terminate (fin, 1 ms)

Cette fois, l'action a été appelée, avec [null] (qui s'écrit comme une chaîne vide : [carre()]), et c'est elle qui a levé l'erreur 400. Une erreur « imprévue », [/carre/13] :

Image

--- GET /carre/13
1. Journal, kernel.request (avant le routage)
[info] Matched route "carre".
2. Bloquer, kernel.request (route : carre)
3. Chrono, kernel.controller (avant l'action)
4. EntierResolver, les arguments de l'action (valeur reçue : "13")
5. action carre(13)
[critical] Uncaught PHP Exception DomainException: "13 porte malheur" at CalculController.php line 28
x. PageErreur, kernel.exception (DomainException : 13 porte malheur)
6. Chrono, kernel.response (code 500)
7. Journal, kernel.response (code 500)
8. Chrono, kernel.terminate (fin, 1 ms)

L'exception a été traitée par notre écouteur : l'utilisateur voit la page d'erreur « maison », et non la page détaillée, bien que le mode debug soit actif. Elle a tout de même été écrite dans le journal, avec le niveau [critical] (une erreur HTTP n'est qu'une [error]) : comme [Laravel], [Symfony] distingue l'enregistrement d'une exception dans le journal de la réponse qu'elle produit. Enfin, une URL qui ne correspond à aucune route, [/inconnu] :

1
2
3
4
5
--- GET /inconnu
1. Journal, kernel.request (avant le routage)
[error] Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\NotFoundHttpException: "No route found for "GET http://localhost:8000/inconnu"" at RouterListener.php line 156
x. PageErreur, kernel.exception (HTTP 404)
7. Journal, kernel.response (code 404)

Le routage a échoué : aucun écouteur après lui, aucune action. La page 404 est la nôtre :

curl http://localhost:8000/inconnu
1
2
3
4
5
6
...
      <div class="alert alert-danger">
    <h1 class="h4">Erreur 404</h1>
        <p class="mb-0">Page introuvable.</p>
  </div>
...

Retenons l'essentiel. Les écouteurs des événements du noyau sont le bon endroit pour les vérifications communes à de nombreuses actions (l'utilisateur est-il connecté ? a-t-il le droit d'accéder à cette action ?) : c'est sur eux que repose le composant de sécurité de [Symfony], que le chapitre suivant utilise pour l'authentification. Les écouteurs de [kernel.exception] et les vues d'erreur garantissent que l'utilisateur ne voit jamais une page technique, quelle que soit l'erreur : l'étude de cas en tire parti.