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.
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énement | Ce 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 :
| Constante | Valeur string | Classe é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 :
La requête HTTP reçue du client. Déclenche TERMINATE après l'envoi de la réponse.
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.
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
}
}
$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 :
| Listener | Priorité | 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)
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
- Vérifier le cache HTTP (ETag, Last-Modified)
- Retourner 304 Not Modified directement
- Aucun contrôleur instancié
- 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.
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());
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 :
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.
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.
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 :
| Resolver | Ce qu'il résout |
|---|---|
RequestValueResolver | Paramètre typé Request → la requête courante |
RequestAttributeValueResolver | Paramètre dont le nom est dans $request->attributes |
BackedEnumValueResolver | Enum backed depuis un attribut de route |
UidValueResolver | UUID/ULID depuis un attribut de route |
ServiceValueResolver | Service injecté via #[Autowire] sur un paramètre de contrôleur |
DefaultValueResolver | Valeur 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);
}
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.
ValueResolverInterface et taggé
controller.argument_value_resolver. C'est l'alternative moderne aux anciens
ParamConverter.
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));
}
}
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)));
}
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.
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());
}
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.
$event->isMainRequest() si ton comportement doit
s'appliquer seulement à la requête principale (headers de sécurité, analytics, etc.).
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 :
| Exception | Code HTTP |
|---|---|
BadRequestHttpException | 400 |
UnauthorizedHttpException | 401 |
AccessDeniedHttpException | 403 |
NotFoundHttpException | 404 |
MethodNotAllowedHttpException | 405 |
ConflictHttpException | 409 |
UnprocessableEntityHttpException | 422 |
TooManyRequestsHttpException | 429 |
ServiceUnavailableHttpException | 503 |
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.
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() 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.
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();
}
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
- Flush des logs (Monolog BufferHandler)
- Envoi d'emails asynchrones (non critiques)
- Mise à jour de statistiques / analytics
- Nettoyage de ressources temporaires
- 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)
$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.
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
| Aspect | MAIN_REQUEST | SUB_REQUEST |
|---|---|---|
| kernel.terminate | Oui | Non |
| Contexte routeur | Défini | Restauré via FINISH_REQUEST |
| isMainRequest() | true | false |
| RequestStack | Niveau 1 | Niveau 2+ |
| Session | Partagée | Mê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
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.
$event->isMainRequest() pour ne pas le rejouer sur les sub-requests.
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énement | Pourquoi |
|---|---|---|
| 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
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.
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).
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().
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énement | Classe | Méthodes clés |
|---|---|---|
kernel.request | RequestEvent | getRequest(), setResponse() |
kernel.controller | ControllerEvent | getController(), setController(), getAttributes() |
kernel.controller_arguments | ControllerArgumentsEvent | getArguments(), setArguments(), getNamedArguments() |
kernel.view | ViewEvent | getControllerResult(), setResponse() |
kernel.response | ResponseEvent | getResponse(), isMainRequest() |
kernel.finish_request | FinishRequestEvent | getRequest(), isMainRequest() |
kernel.exception | ExceptionEvent | getThrowable(), setThrowable(), setResponse(), allowCustomResponseCode() |
kernel.terminate | TerminateEvent | getRequest(), getResponse(), getKernel() |