← Tous les cours / HttpKernel — le cycle de vie d'une requête Cours
00 — LE NOYAU

Le noyau et son cycle de vie

Une requête HTTP entre, une réponse sort — entre les deux, 8 événements orchestrés par HttpKernel.

Symfony reçoit une requête HTTP et retourne une réponse. Entre les deux, une séquence déterministe de 8 étapes parcourt le noyau. Comprendre ce pipeline, c'est comprendre où intervenir pour modifier le comportement de n'importe quelle application Symfony.

HttpKernel::handle()
La méthode centrale. Elle pousse la requête sur la RequestStack, appelle handleRaw() dans un try-finally, gère les exceptions si $catch === true, et nettoie la pile en sortie. C'est ce que Kernel::handle() appelle après le boot.

La séquence exacte de handleRaw()

#ÉtapeÉvénementCe qui se passe
1 REQUEST kernel.request Routage, session, locale. Un listener peut fournir une réponse directe.
2 Résolution contrôleur ControllerResolver::getController() lit _controller dans les attributs.
3 CONTROLLER kernel.controller Le contrôleur peut être remplacé ou décoré.
4 Résolution arguments ArgumentResolver::getArguments() résout chaque paramètre par type hint puis par nom.
5 CONTROLLER_ARGUMENTS kernel.controller_arguments Les arguments résolus peuvent être modifiés.
6 Appel contrôleur $response = $controller(...$arguments)
7 VIEW (si besoin) kernel.view Si le contrôleur n'a pas retourné une Response, les listeners la construisent.
8 RESPONSE kernel.response Modification finale de la réponse (headers, charset…).

Les 8 événements KernelEvents

Chaque événement a une constante dans KernelEvents et une classe dédiée :

ConstanteValeur stringClasse événement
REQUEST'kernel.request'RequestEvent
CONTROLLER'kernel.controller'ControllerEvent
CONTROLLER_ARGUMENTS'kernel.controller_arguments'ControllerArgumentsEvent
VIEW'kernel.view'ViewEvent
RESPONSE'kernel.response'ResponseEvent
FINISH_REQUEST'kernel.finish_request'FinishRequestEvent
EXCEPTION'kernel.exception'ExceptionEvent
TERMINATE'kernel.terminate'TerminateEvent

MAIN_REQUEST vs SUB_REQUEST

Chaque requête traitée par HttpKernel::handle() est typée :

HttpKernelInterface::MAIN_REQUEST = 1
La requête HTTP reçue du client. Déclenche TERMINATE après l'envoi de la réponse.
HttpKernelInterface::SUB_REQUEST = 2
Requête interne (fragments ESI, include de contrôleur). Ne déclenche pas TERMINATE. Le contexte du routeur est restauré via FINISH_REQUEST.

Lance la simulation ci-dessous pour voir le pipeline s'exécuter :

Clique sur Envoyer une requête pour voir le pipeline.

01 — KERNEL.REQUEST

kernel.request — le portail d'entrée

Le premier événement du cycle — là où une requête peut être résolue en réponse sans jamais atteindre un contrôleur.

kernel.request est le premier événement dispatché. Il est déclenché avant toute résolution de contrôleur. C'est ici que le routeur fait son travail — et c'est ici qu'on peut court-circuiter tout le pipeline pour retourner une réponse directement.

RequestEvent

use Symfony\Component\HttpKernel\Event\RequestEvent;

public function onKernelRequest(RequestEvent $event): void
{
    $request = $event->getRequest();

    // Court-circuiter le pipeline :
    if ($this->cache->has($request)) {
        $event->setResponse($this->cache->get($request));
        // Le pipeline saute directement à kernel.response
    }
}
🔑 Court-circuit : dès qu'un listener appelle $event->setResponse(), le pipeline saute les étapes 2 à 7 (contrôleur, arguments, view) et passe directement à kernel.response. Aucun contrôleur n'est invoqué.

Les listeners core (REQUEST)

Les listeners sont exécutés par ordre de priorité décroissante :

ListenerPrioritéRôle
LocaleListener 34 Définit la locale depuis la route ou Accept-Language
RouterListener 32 Matching de route → popule _controller, _route, _route_params
SessionListener default Injection lazy de la session dans la requête
Ton listener < 32 Après le routage (accès à _controller)

$request->attributes — le sac partagé

Après le routage, la requête porte ces attributs dans son ParameterBag :

$request->attributes->get('_controller');   // 'App\Controller\HomeController::index'
$request->attributes->get('_route');        // 'app_home'
$request->attributes->get('_route_params'); // ['id' => '42']
$request->attributes->get('_locale');      // 'fr' (si route paramétrée)
Injecter des données pour le contrôleur
N'importe quel listener REQUEST peut injecter des données dans $request->attributes. L'ArgumentResolver les mettra ensuite à disposition des paramètres du contrôleur portant le même nom — c'est le mécanisme derrière #[MapEntity] ou les ParamConverter.

Cas d'usage du court-circuit

Réponse HTTP cache
  • Vérifier le cache HTTP (ETag, Last-Modified)
  • Retourner 304 Not Modified directement
  • Aucun contrôleur instancié
Maintenance / Feature flags
  • Retourner 503 si mode maintenance actif
  • A/B testing : répondre différemment selon cookie
  • Blocage IP / rate limiting

Choisis le scénario et clique sur Exécuter.

02 — KERNEL.CONTROLLER

kernel.controller — qui prend la main

Une fois le contrôleur trouvé, cet événement permet de l'intercepter, le remplacer ou l'instrumenter.

Après le matching de route, Symfony sait quel contrôleur appeler. Avant de l'appeler, il dispatche kernel.controller pour donner la possibilité de l'intercepter — le changer, le décorer, ou simplement inspecter ses attributs PHP.

ControllerEvent

use Symfony\Component\HttpKernel\Event\ControllerEvent;

public function onKernelController(ControllerEvent $event): void
{
    $controller = $event->getController();

    // Remplacer le contrôleur
    $event->setController(function () use ($controller) {
        return new JsonResponse(['wrapped' => $controller()]);
    });

    // Lire les attributs PHP du contrôleur
    $attributes = $event->getAttributes(MyAttribute::class);
}

ControllerResolverInterface

Avant que kernel.controller soit dispatché, Symfony résout le callable depuis $request->attributes->get('_controller') :

// Formats supportés par ControllerResolver
$request->attributes->set('_controller', 'App\Controller\HomeController::index');
$request->attributes->set('_controller', [new HomeController(), 'index']);
$request->attributes->set('_controller', function(Request $r) { return new Response(); });
$request->attributes->set('_controller', new InvokableController());
ControllerResolverInterface::getController(Request): callable|false
Retourne false si le contrôleur ne peut pas être résolu — un autre resolver peut alors essayer. Lance NotFoundHttpException si _controller est absent des attributs.

Cas d'usage de kernel.controller

Cet événement est adapté pour les préoccupations transversales — ce qu'on applique à plusieurs contrôleurs sans les modifier :

Instrumenter sans modifier
Un listener kernel.controller peut logger l'heure de début d'exécution, stocker le contrôleur dans un attribut de requête pour des métriques, ou vérifier des pré-conditions (ex. contrôleur non déprécié) — sans toucher au code du contrôleur.
⚠️ Piège : getAttributes() sur ControllerEvent lit les attributs PHP 8 de la méthode de contrôleur. Cela requiert la réflexion. Si la méthode n'est pas réfléchissable (closure anonyme), getAttributes() retourne un tableau vide.
03 — KERNEL.CONTROLLER_ARGUMENTS

kernel.controller_arguments — les arguments résolus

Avant l'appel du contrôleur, cet événement expose les arguments résolus — on peut les lire, les valider, les transformer.

L'argument resolver transforme la signature du contrôleur en un tableau de valeurs concrètes. L'événement kernel.controller_arguments est dispatché après cette résolution — les arguments sont visibles et modifiables avant l'appel.

ArgumentResolverInterface

// Signature de l'interface
interface ArgumentResolverInterface
{
    public function getArguments(
        Request   $request,
        callable  $controller,
        ?\Reflector $reflector = null,
    ): array;
}

L'implémentation standard (ArgumentResolver) parcourt les paramètres de la signature du contrôleur et demande à chaque ValueResolverInterface de les résoudre :

ResolverCe qu'il résout
RequestValueResolverParamètre typé Request → la requête courante
RequestAttributeValueResolverParamètre dont le nom est dans $request->attributes
BackedEnumValueResolverEnum backed depuis un attribut de route
UidValueResolverUUID/ULID depuis un attribut de route
ServiceValueResolverService injecté via #[Autowire] sur un paramètre de contrôleur
DefaultValueResolverValeur par défaut du paramètre si aucun resolver n'a répondu

ControllerArgumentsEvent

use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;

public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
{
    // Arguments résolus (tableau positionnel)
    $args = $event->getArguments();

    // Arguments nommés (nom paramètre → valeur)
    $named = $event->getNamedArguments(); // ['id' => 42, 'request' => Request]

    // Modifier un argument
    $args[0] = $this->transformer->transform($args[0]);
    $event->setArguments($args);
}
🔑 Ordre de résolution : l'ArgumentResolver essaie chaque ValueResolver dans l'ordre de leur priorité (décroissante). Le premier qui retourne un résultat non vide gagne. Pour un paramètre typé Request, le RequestValueResolver gagne toujours — pas besoin de le nommer $request.
💡 Custom ValueResolver : pour injecter une entité depuis un UUID de route, crée un service implémentant ValueResolverInterface et taggé controller.argument_value_resolver. C'est l'alternative moderne aux anciens ParamConverter.
04 — KERNEL.VIEW

kernel.view — transformer le résultat

Quand le contrôleur ne retourne pas une Response, cet événement transforme son résultat — c'est le mécanisme derrière les API REST et les moteurs de templates.

Le contrôleur peut retourner n'importe quoi : un tableau, un objet DTO, une chaîne. Si ce n'est pas une Response Symfony, kernel.view est dispatché pour que les listeners transforment ce résultat. C'est le mécanisme central des API REST et des moteurs de templates.

ViewEvent

use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpFoundation\JsonResponse;

public function onKernelView(ViewEvent $event): void
{
    $result = $event->getControllerResult();

    // Sérialiser en JSON si le format demandé est 'json'
    if ($event->getRequest()->getRequestFormat() === 'json') {
        $event->setResponse(new JsonResponse($result));
    }
}
🚫 Si aucun listener ne fournit de réponse : Symfony lance ControllerDoesNotReturnResponseException — une erreur courante quand on oublie de retourner une Response depuis un contrôleur et qu'il n'y a pas de listener VIEW.

Patterns courants

Sérialisation JSON automatique

// Le contrôleur retourne un tableau ou un objet
public function index(): array
{
    return ['users' => $this->repository->findAll()];
}

// Un listener VIEW le convertit en JsonResponse
public function onKernelView(ViewEvent $event): void
{
    $event->setResponse(
        new JsonResponse($this->serializer->normalize($event->getControllerResult()))
    );
}

Rendu de templates

// Retourner un ViewModel depuis le contrôleur
public function show(int $id): ViewModel
{
    return new ViewModel(template: 'product/show.html.twig', data: $this->find($id));
}

// Un listener VIEW rend le template
public function onKernelView(ViewEvent $event): void
{
    if (!($event->getControllerResult() instanceof ViewModel)) { return; }
    $vm = $event->getControllerResult();
    $event->setResponse(new Response($this->twig->render($vm->template, $vm->data)));
}
getRequest()->getRequestFormat()
Retourne le format de la requête : 'html' par défaut, 'json' si l'URL se termine par .json ou si l'en-tête Accept contient application/json (selon la configuration). C'est l'outil principal pour les API multi-format.
05 — KERNEL.RESPONSE

kernel.response — la dernière touche

La réponse existe — cet événement permet de l'enrichir, de la modifier, d'ajouter des headers avant l'envoi.

À ce stade, la réponse existe — qu'elle vienne du contrôleur, d'un listener REQUEST, ou d'un listener VIEW. kernel.response permet de la modifier avant l'envoi. C'est l'endroit idéal pour les headers de sécurité, la compression, et les modifications globales de réponse.

ResponseEvent

use Symfony\Component\HttpKernel\Event\ResponseEvent;

public function onKernelResponse(ResponseEvent $event): void
{
    // Appliquer uniquement à la requête principale
    if (!$event->isMainRequest()) { return; }

    $response = $event->getResponse();
    $response->headers->set('X-Frame-Options', 'DENY');
    $response->headers->set('X-Content-Type-Options', 'nosniff');
    $response->headers->set('Strict-Transport-Security', 'max-age=31536000');
}

ResponseListener — le listener core

ResponseListener est le seul listener RESPONSE fourni par défaut dans HttpKernel. Il appelle $response->prepare($request) pour fixer le charset, le protocole HTTP et les headers obligatoires :

// Ce que prepare() fait automatiquement :
// - Content-Type: text/html; charset=UTF-8 si absent
// - Content-Length si la réponse n'est pas chunked
// - Supprime le body pour les requêtes HEAD et les 1xx/204/304
$response->prepare($request);

FINISH_REQUEST — le nettoyage

kernel.finish_request est dispatché après RESPONSE, pour les deux types de requêtes (principale et sub-request). Il sert au nettoyage d'état :

use Symfony\Component\HttpKernel\Event\FinishRequestEvent;

public function onKernelFinishRequest(FinishRequestEvent $event): void
{
    // RouterListener restaure ici le contexte du routeur parent
    // (important pour les sub-requests)
    $this->router->getContext()->fromRequest($event->getRequest());
}
Ordre RESPONSE → FINISH_REQUEST
RESPONSE peut encore modifier la réponse. FINISH_REQUEST ne doit que nettoyer l'état global — la réponse ne sera plus modifiée après. Les listeners FINISH_REQUEST ne reçoivent pas la réponse dans l'événement.
💡 isMainRequest() dans RESPONSE : un listener RESPONSE recevra l'événement pour les sub-requests aussi. Vérifie $event->isMainRequest() si ton comportement doit s'appliquer seulement à la requête principale (headers de sécurité, analytics, etc.).
06 — KERNEL.EXCEPTION

kernel.exception — capturer les erreurs

Une exception non attrapée déclenche cet événement — c'est ici que les pages d'erreur, les logs et les conversions HTTP prennent vie.

Quand une exception n'est pas attrapée par le contrôleur, elle remonte jusqu'au kernel qui dispatche kernel.exception. C'est le filet de sécurité global — chaque application Symfony a au moins un listener ici.

ExceptionEvent

use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpFoundation\JsonResponse;

public function onKernelException(ExceptionEvent $event): void
{
    $e = $event->getThrowable();

    // Remplacer l'exception par une autre
    $event->setThrowable(new BadRequestHttpException('Invalid input', $e));

    // Ou fournir une réponse (avale l'exception)
    if ($event->getRequest()->getRequestFormat() === 'json') {
        $event->setResponse(new JsonResponse([
            'error' => $e->getMessage(),
        ], $this->getStatusCode($e)));
    }
}

HttpExceptionInterface — le mapping automatique

Symfony mappe automatiquement les exceptions implémentant HttpExceptionInterface au code HTTP correspondant. Les exceptions du composant symfony/http-kernel couvrent les cas les plus courants :

ExceptionCode HTTP
BadRequestHttpException400
UnauthorizedHttpException401
AccessDeniedHttpException403
NotFoundHttpException404
MethodNotAllowedHttpException405
ConflictHttpException409
UnprocessableEntityHttpException422
TooManyRequestsHttpException429
ServiceUnavailableHttpException503

ErrorListener et la priorité

ErrorListener de Symfony a la priorité -64 — il s'exécute en dernier. Tes listeners EXCEPTION s'exécutent avant lui, ce qui te permet de traiter l'exception et de fournir une réponse avant qu'ErrorListener rende sa page d'erreur.

Ton listener (0)
Autre listener (-10)
ErrorListener (-64)
Page d'erreur

allowCustomResponseCode()

// Par défaut, Symfony écrase le code HTTP par celui de l'exception
// Pour conserver un code custom dans ta réponse :
$event->allowCustomResponseCode();
$event->setResponse(new Response('Custom', 418)); // Gardé tel quel
⚠️ setThrowable vs setResponse : setThrowable() remplace l'exception dans l'événement — les listeners suivants voient la nouvelle exception. setResponse() avale l'exception et fournit une réponse — les listeners suivants voient la réponse, pas l'exception. Les deux peuvent être combinés.
07 — KERNEL.TERMINATE

kernel.terminate — après l'envoi

kernel.terminate s'exécute après que la réponse ait été envoyée au client — c'est la zone des tâches coûteuses qui ne doivent pas ralentir la réponse.

kernel.terminate est unique dans le pipeline : il s'exécute après que la réponse ait été envoyée au client. Le navigateur a déjà reçu les données. C'est la zone sans pression de temps — idéale pour les tâches coûteuses.

Comment TERMINATE est déclenché

// Dans public/index.php (généré par Symfony)
$response = $kernel->handle($request);
$response->send();            // ← Le client reçoit la réponse ici
$kernel->terminate($request, $response); // ← TERMINATE dispatché ici

Avec FrankenPHP, le mécanisme est équivalent — la fibre de réponse est résolue avant que terminate() soit appelé.

TerminateEvent

use Symfony\Component\HttpKernel\Event\TerminateEvent;

public function onKernelTerminate(TerminateEvent $event): void
{
    $request  = $event->getRequest();   // Toujours la main request
    $response = $event->getResponse();  // La réponse déjà envoyée

    // Tâches post-réponse sécurisées
    $this->emailQueue->flush();
    $this->analytics->track($request, $response);
    $this->logger->flush();
}
Uniquement MAIN_REQUEST
Kernel::terminate() ne dispatche TERMINATE que pour la requête principale. Les sub-requests ne déclenchent jamais TERMINATE — elles ont leur propre FINISH_REQUEST.

Cas d'usage légitimes

Bon usage
  • Flush des logs (Monolog BufferHandler)
  • Envoi d'emails asynchrones (non critiques)
  • Mise à jour de statistiques / analytics
  • Nettoyage de ressources temporaires
Mauvais usage
  • Opérations critiques (peuvent ne pas s'exécuter)
  • Modifications de données métier importantes
  • Tout ce qui doit être garanti (utilise plutôt une queue)
🚫 TERMINATE n'est pas garanti : si $response->send() lève une exception, terminate() n'est jamais appelé. Pour les opérations critiques, utilise Messenger ou une autre queue de messages persistante.

Observe la frise chronologique ci-dessous :

Clique sur Envoyer pour voir la séquence complète.

08 — SUB-REQUESTS

Sub-requests et fragments

Un kernel peut traiter plusieurs requêtes dans une seule exécution — c'est le mécanisme derrière les fragments ESI et les includes de contrôleurs.

Symfony peut traiter plusieurs requêtes dans une seule exécution PHP. Quand tu appelles $kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST), le noyau crée un pipeline complet pour cette sous-requête — avec ses propres événements, son propre contrôleur, sa propre réponse.

Créer et traiter une sub-request

use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpFoundation\Request;

// Dans un contrôleur ou listener
$subRequest = Request::create('/api/user/42', 'GET');
$response   = $this->httpKernel->handle(
    $subRequest,
    HttpKernelInterface::SUB_REQUEST,
    false, // $catch : laisser remonter les exceptions
);

Ce qui change avec SUB_REQUEST

AspectMAIN_REQUESTSUB_REQUEST
kernel.terminateOuiNon
Contexte routeurDéfiniRestauré via FINISH_REQUEST
isMainRequest()truefalse
RequestStackNiveau 1Niveau 2+
SessionPartagéeMême session

RequestStack — la pile des requêtes

use Symfony\Component\HttpFoundation\RequestStack;

$stack->getMainRequest();    // Toujours la requête principale
$stack->getCurrentRequest(); // La requête en cours de traitement (main ou sub)
$stack->getParentRequest();  // La requête parente dans les sub-requests imbriquées
ESI (Edge Side Includes)
Le cas d'usage le plus courant des sub-requests : Symfony rend des fragments de page indépendamment, chacun avec sa propre réponse HTTP et ses propres headers de cache. {{ render(path('app_user_menu')) }} dans un template Twig déclenche une sub-request.
⚠️ Évite les sub-requests en boucle : chaque sub-request instancie un pipeline complet avec ses événements. Pour des données à récupérer en masse, un service direct (repository, API call) est toujours plus rapide qu'une boucle de sub-requests.
🔑 isMainRequest() dans les listeners : si un listener REQUEST applique un comportement (session, sécurité, locale) qui ne doit s'appliquer qu'à la requête principale, vérifie $event->isMainRequest() pour ne pas le rejouer sur les sub-requests.
09 — SYNTHÈSE

Synthèse — guide de choix des événements

8 événements, 1 pipeline — choisir le bon endroit d'interception selon ce qu'on veut faire.

Le pipeline HttpKernel est un système à événements. Chaque événement a un rôle précis dans la séquence. Choisir le bon événement, c'est écrire des listeners qui font exactement ce qu'ils doivent — ni avant, ni après.

Guide de choix

BesoinÉvénementPourquoi
Modifier la requête avant le routage kernel.request (priorité > 32) RouterListener est à 32 — avant lui, la route n'est pas encore résolue
Court-circuiter vers une réponse (cache, maintenance) kernel.request setResponse() saute le contrôleur
Changer de contrôleur dynamiquement kernel.controller Après la résolution, avant l'appel
Modifier les arguments avant l'appel kernel.controller_arguments Arguments déjà résolus, modifiables
Sérialiser le retour du contrôleur (JSON, XML) kernel.view Déclenché uniquement si pas de Response
Ajouter des headers globaux à la réponse kernel.response Réponse disponible et modifiable
Gérer les exceptions globalement kernel.exception Avant ErrorListener (-64)
Tâches post-réponse non critiques kernel.terminate Après envoi, client n'attend plus

Anti-patterns

🚫 Logique métier dans un listener REQUEST.
Les listeners d'événements kernel sont des préoccupations transversales (routage, sécurité, cache). La logique métier appartient au contrôleur ou à tes services métier — pas aux listeners.
🚫 Opérations lentes dans kernel.response.
RESPONSE bloque l'envoi de la réponse. Si une opération prend du temps (email, log distant), déplace-la dans TERMINATE — ou mieux, dans une queue de messages (Messenger).
🚫 Ignorer isMainRequest() dans les listeners.
Un listener RESPONSE ou REQUEST reçoit l'événement pour les sub-requests aussi. Si ton comportement ne doit s'appliquer qu'à la requête principale (headers, session, analytics), vérifie $event->isMainRequest().
🚫 Sub-requests en boucle.
Chaque sub-request instancie un pipeline complet. Pour 100 items, préfère un service direct. Les sub-requests sont faites pour des fragments de page indépendants, pas pour du batch.

Récapitulatif des classes d'événements

ÉvénementClasseMéthodes clés
kernel.requestRequestEventgetRequest(), setResponse()
kernel.controllerControllerEventgetController(), setController(), getAttributes()
kernel.controller_argumentsControllerArgumentsEventgetArguments(), setArguments(), getNamedArguments()
kernel.viewViewEventgetControllerResult(), setResponse()
kernel.responseResponseEventgetResponse(), isMainRequest()
kernel.finish_requestFinishRequestEventgetRequest(), isMainRequest()
kernel.exceptionExceptionEventgetThrowable(), setThrowable(), setResponse(), allowCustomResponseCode()
kernel.terminateTerminateEventgetRequest(), getResponse(), getKernel()