HttpKernel & architecture Symfony
TL;DR — Symfony is, at its core, a function
HttpKernel::handle(Request): Response. Tout le reste (routing, security, twig, doctrine) n'est qu'un ensemble de event listeners branchés sur 6 événements clés du kernel. Comprendre ce pipeline = pouvoir débugger n'importe quelle requête, écrire des middlewares custom, et choisir entre full-stack et micro-kernel en connaissance de cause.
🧠 Mental model — ASCII diagram + analogie
Symfony = un bus à 6 arrêts. Tu mets une Request à l'entrée, à chaque arrêt des listeners peuvent monter, modifier le trajet, ou court-circuiter directement vers la sortie avec une Response.
┌─────────────────────────────────────────────┐
Request ─────► HttpKernel::handle($request) │
│ │
▼ │
┌──► kernel.request ── (Router, Firewall, Locale) ──┤
│ │ ↳ peut setResponse() ──────────────► short-circuit
│ ▼ │
│ ControllerResolver → resolve($request) │
│ │ │
│ ▼ │
├──► kernel.controller (CSRF, ParamConverter, etc.) │
│ │ │
│ ▼ │
│ ArgumentResolver → resolveArguments() │
│ │ │
│ ▼ │
│ call_user_func_array($controller, $args) │
│ │ │
│ ├── retour Response ─────► kernel.response │
│ └── retour autre ─► kernel.view → Response ──►│
│ │
└──► kernel.exception (si throw à n'importe quel niveau)
│
▼
Response ─────────────────────────────────────────► Client
│
▼
kernel.terminate (après l'envoi de la réponse)Analogie : c'est un aéroport. La Request est ton passager. kernel.request = contrôle de sécurité (firewall, passeport). kernel.controller = embarquement (préparation des arguments). kernel.view = conversion en bagage (sérialisation si nécessaire). kernel.response = duty-free (ajout headers, cookies). kernel.exception = service médical. kernel.terminate = nettoyage des cabines (envoi emails, log) après que l'avion a décollé.
🛠️ Code minimal — bundle-less app (PHP 8.2+)
// public/index.php
<?php
use App\Kernel;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};// src/Kernel.php
<?php
namespace App;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
final class Kernel extends BaseKernel
{
use MicroKernelTrait;
#[Route('/ping', name: 'ping', methods: ['GET'])]
public function ping(Request $request): JsonResponse
{
return new JsonResponse(['pong' => true, 'env' => $this->environment]);
}
}Listener custom branché sur kernel.request :
// src/EventListener/RequestIdListener.php
<?php
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;
final readonly class RequestIdListener
{
#[AsEventListener(event: KernelEvents::REQUEST, priority: 256)]
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$event->getRequest()->attributes->set(
'_request_id',
$event->getRequest()->headers->get('X-Request-Id') ?? bin2hex(random_bytes(8))
);
}
#[AsEventListener(event: KernelEvents::RESPONSE)]
public function onResponse(ResponseEvent $event): void
{
$id = $event->getRequest()->attributes->get('_request_id');
if ($id) {
$event->getResponse()->headers->set('X-Request-Id', $id);
}
}
}🎯 Patterns courants
- Short-circuit dans
kernel.request— pour maintenance mode, IP whitelist, rate limiting :$event->setResponse(new Response('Maintenance', 503))arrête le pipeline avant même de toucher le controller. - Sub-requests —
$kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST)pourrender(controller())Twig ; n'émet paskernel.terminate, certaines features (firewall main) sont skip. kernel.terminatepour le slow work — envoi d'emails, push analytics, write logs lents : le client a déjà reçu la réponse, on traite après. Requiertfastcgi_finish_request()ou PHP-FPM pour le vrai effet.- Exception listener pour API uniforme — un seul
kernel.exceptionlistener qui convertitHttpException,ValidationFailedException, etc. en JSON RFC 7807 (Problem Details). kernel.viewpour controllers qui retournent des DTOs — au lieu de renvoyerResponse, le controller retourne un objet, et un listener le serialize en JSON via leSerializer. C'est ce que fait API Platform.- FrontController unique —
public/index.phpest la seule porte d'entrée ; jamais d'autre fichier PHP accessible publiquement. Tout passe par le kernel → un seul endroit pour logger, sécuriser, observer.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
- 5.4 LTS : annotations Doctrine
@Routetoujours OK mais déjà découragées ; attributs PHP 8#[Route]disponibles.EventSubscriberInterfacereste la norme pour les listeners. - 6.0 : suppression des annotations (lib
doctrine/annotationsdevient optionnelle), attributs PHP partout. Apparition de#[AsEventListener](Symfony 6.1) → plus besoin d'implémenterEventSubscriberInterfacepour un listener simple. - 6.3 :
#[AsController](déjà là), amélioration de l'autowiring desArgumentResolverInterface. - 6.4 LTS : stable, base recommandée pour migration vers 7.x.
kernel.terminateevents bénéficient duRoadRunner/FrankenPHPworker mode (attention au state leak entre requêtes). - 7.0 : suppression de
EventDispatcherInterface::dispatch()legacy signature, supression de plusieurs deprecatedKernelmethods.Request::HEADER_X_FORWARDED_*constants renommées. - 7.1+ : amélioration du
TerminableInterface, meilleure intégration worker mode.#[AsEventListener]peut être posé sur une classe entière sans méthode dédiée.
⚠️ Pitfalls
- Confondre main request et sub-request — un listener qui ne check pas
$event->isMainRequest()peut s'exécuter 10× par page (chaquerender(controller())Twig est une sub-request). - Modifier la Response dans
kernel.terminate— trop tard, elle est déjà envoyée au client. Utiliserkernel.responsepour ajouter headers/cookies. - Throw dans
kernel.terminate— l'exception est swallowée silencieusement (le client a déjà sa réponse). Logger explicitement, sinon bugs invisibles. - Priority hell — un listener à
kernel.requestpriority 0 vs Firewall (priority 8). Lister avecdebug:event-dispatcher kernel.requestpour voir l'ordre réel. - Worker mode (FrankenPHP/RoadRunner) + state global — variables static,
$_GET/$_POST, container booté une seule fois : tout state non-reset = leak entre requêtes. Toujours utiliserRequestStack, jamais$_SERVER. - Oublier
fastcgi_finish_request—kernel.terminatene bloque pas le client uniquement si PHP-FPM ou équivalent ; en CLI ou avecbuilt-in server, c'est sync. kernel.exceptionqui throw à nouveau — boucle infinie évitée par le kernel via flag interne, mais la 2e exception est masquée. Toujours try/catch dans ces listeners.- MicroKernel sans cache dir — pour un script CLI one-shot, oublier de définir
getCacheDir()lance des warmups inutiles à chaque run.
🧪 Testing
// tests/Functional/KernelEventsTest.php
<?php
namespace App\Tests\Functional;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class KernelEventsTest extends WebTestCase
{
public function testRequestIdHeaderIsPropagated(): void
{
$client = static::createClient();
$client->request('GET', '/ping', server: ['HTTP_X_REQUEST_ID' => 'abc-123']);
self::assertResponseIsSuccessful();
self::assertSame('abc-123', $client->getResponse()->headers->get('X-Request-Id'));
}
public function testTerminateListenerRuns(): void
{
$client = static::createClient();
$client->request('GET', '/ping');
$client->getKernel()->terminate($client->getRequest(), $client->getResponse());
// Vérifier ici les side effects (mailer, logs, etc.)
}
}Pour tester un listener isolé, instancier directement l'event et appeler la méthode :
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
$event = new RequestEvent($kernel, Request::create('/ping'), HttpKernelInterface::MAIN_REQUEST);
(new RequestIdListener())->onRequest($event);
self::assertNotNull($event->getRequest()->attributes->get('_request_id'));🎬 Cas d'usage concrets
Scénario 1 — SaaS RH (Lucca-like) : isolation multi-tenant via kernel.request
Contexte : un SaaS RH français multi-tenant héberge 800 PME. Chaque sous-domaine (acme.lucca.fr, cabinet-mercier.lucca.fr) doit pointer sur le bon tenant avec sa propre base de données et son propre branding. Sans cette résolution, un manager d'Acme pourrait théoriquement voir les bulletins de paie d'un autre client — risque RGPD majeur.
L'équipe place un listener sur kernel.request à priority 40 (juste au-dessus du Router) qui parse le Host header, charge la Tenant correspondante depuis Redis (cache 5 min), et la pose dans $request->attributes. Toute la stack downstream (Doctrine connection switcher, Twig brand context, security firewall) lit cet attribut. En WebTestCase, on injecte HTTP_HOST=test-tenant.lucca.fr dans createClient() pour tester en isolation.
Bénéfice mesuré : aucune fuite cross-tenant détectée en 18 mois de production, et l'ajout d'un nouveau client se fait par simple INSERT en base (zero deploy).
Scénario 2 — E-commerce Mode (Sézane-like) : tracking analytics non-bloquant via kernel.terminate
Contexte : un retailer mode FR doit envoyer chaque page-view à Segment, Mixpanel et un data lake interne sur S3. Côté front les conversions doivent rester sous 200 ms TTFB. Trois listeners séparés taggés kernel.event_listener event kernel.terminate fanout les events après envoi de la Response. La fonction fastcgi_finish_request() est appelée explicitement pour libérer la socket Nginx.
Mesures : le P95 TTFB est passé de 480 ms à 165 ms après refactor, alors que les 3 trackings (~250 ms cumulés) tournent désormais en arrière-plan. Côté code, un kernel.response listener prépare les payloads (cookies user, session ID, basket value via Money\Money) et les stocke dans $request->attributes->set('_analytics_payload', $dto) ; le terminate les consomme.
Scénario 3 — Banque en ligne (Boursorama-like) : exception handler centralisé RFC 7807
Contexte : une néo-banque expose ~150 endpoints REST internes pour mobile et conseillers. La compliance exige que toutes les erreurs (validation, auth, 500) soient retournées en JSON Problem Details (RFC 7807) avec un trace_id corrélable Datadog, sans jamais leak de stack trace.
Un seul listener sur kernel.exception (priority -256, donc après le ExceptionListener Symfony qui n'agit pas en mode JSON) transforme ValidationFailedException, AccessDeniedHttpException, \DomainException, \PDOException en JsonResponse avec application/problem+json. Le trace_id provient d'OpenTelemetry via le baggage propagator. Les exceptions banking métier (InsufficientFundsException, AmlBlockedException) ont un mapping explicite vers des type URIs documentés dans le portail dev.
Résultat : un seul point d'évolution pour la sécurité (aucun controller ne try/catch l'enveloppe HTTP), et les équipes mobile ont divisé par 3 leur code de gestion d'erreurs grâce au format strictement typé.
🛠️ Exemple end-to-end
Use case : portail conseiller bancaire — endpoint /conseiller/clients/{clientId}/operations qui doit (1) authentifier le conseiller via badge JWT, (2) journaliser l'accès dans un audit log règlementaire ACPR avant retour réponse, (3) renvoyer la liste en JSON, (4) déclencher un push Kafka après envoi de la réponse pour alimenter le data lake risque.
// src/EventListener/AcprAuditListener.php
<?php
declare(strict_types=1);
namespace App\EventListener;
use App\Audit\AuditLogger;
use App\Banking\Entity\ConseillerUser;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
use Symfony\Component\HttpKernel\KernelEvents;
enum AuditAction: string
{
case ReadClientOperations = 'read.client.operations';
case ReadClientProfile = 'read.client.profile';
case ExportStatements = 'export.statements';
}
final readonly class AcprAuditListener
{
public function __construct(
private AuditLogger $audit,
private Security $security,
) {}
#[AsEventListener(event: KernelEvents::REQUEST, priority: 4)]
public function captureAccess(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$route = $event->getRequest()->attributes->get('_route');
$action = match ($route) {
'conseiller_client_operations' => AuditAction::ReadClientOperations,
'conseiller_client_profile' => AuditAction::ReadClientProfile,
'conseiller_export_statements' => AuditAction::ExportStatements,
default => null,
};
if ($action === null) {
return;
}
$user = $this->security->getUser();
\assert($user instanceof ConseillerUser);
$event->getRequest()->attributes->set('_audit_action', $action);
$event->getRequest()->attributes->set('_audit_actor', $user->getMatricule());
}
#[AsEventListener(event: KernelEvents::TERMINATE)]
public function persistAudit(TerminateEvent $event): void
{
$action = $event->getRequest()->attributes->get('_audit_action');
if (!$action instanceof AuditAction) {
return;
}
$this->audit->record(
actor: $event->getRequest()->attributes->get('_audit_actor'),
action: $action->value,
targetId: (string) $event->getRequest()->attributes->get('clientId'),
httpStatus: $event->getResponse()->getStatusCode(),
requestId: $event->getRequest()->attributes->get('_request_id'),
);
}
}// src/Controller/ConseillerOperationsController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Banking\Operations\OperationsReader;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_CONSEILLER')]
final class ConseillerOperationsController
{
public function __construct(private readonly OperationsReader $reader) {}
#[Route(
'/conseiller/clients/{clientId}/operations',
name: 'conseiller_client_operations',
requirements: ['clientId' => '\d{8,12}'],
methods: ['GET'],
)]
public function __invoke(string $clientId): JsonResponse
{
$operations = $this->reader->lastDays($clientId, days: 30);
return new JsonResponse([
'client_id' => $clientId,
'count' => count($operations),
'operations' => array_map(static fn ($op) => $op->toArray(), $operations),
]);
}
}Le flow concret : kernel.request capture l'action ACPR (priority 4, donc après le Firewall priority 8 — l'utilisateur est authentifié), le controller renvoie la JSON, kernel.terminate persiste l'audit en base sans bloquer le conseiller. Un échec d'audit logue mais ne casse jamais la requête : la priorité métier ici est la lecture des opérations.
🔁 Quand utiliser / éviter
- MicroKernel : scripts CLI dédiés, micro-services API ultra-minces (<10 endpoints), serverless. Évite la complexité bundle.
- Full-stack : app web classique, ≥ 1 équipe, ≥ 6 mois de vie → tu vas vouloir Twig/Doctrine/Security/Forms, gain de temps net.
- Bundle-less (Flex sans bundle wrapping) : recommandé par défaut depuis 4.x pour le code applicatif. Garde les bundles uniquement pour le code réutilisable entre projets.
- Listener
kernel.requestcustom : OK pour cross-cutting concerns (request ID, locale, feature flags). Évite pour de la logique métier — ça doit être dans un service appelé depuis un controller.
🧭 Comment un staff engineer raisonne sur le kernel
Le kernel n'est pas un framework, c'est un protocole
La phrase clé du TL;DR — HttpKernel::handle(Request): Response — est plus profonde qu'elle n'en a l'air. HttpKernelInterface est une fonction pure de signature : tout ce qui respecte cette signature est interchangeable. C'est pourquoi :
- PSR-15 (
MiddlewareInterface/RequestHandlerInterface) et le kernel Symfony sont conceptuellement le même objet exprimé différemment : un pipeline(Request) -> Response. Symfony a choisi un modèle event-driven (un dispatcher central, des listeners priorisés) là où Laravel/Slim choisissent un modèle onion middleware (chaque couche wrappe la suivante). Tradeoff : l'event model donne une observabilité globale (debug:event-dispatcherliste tout le pipeline) et un court-circuit trivial (setResponse), au prix d'un ordering implicite par priority. Le middleware onion donne un ordering explicite et lisible (l'ordre du tableau) mais rend le court-circuit et l'introspection plus verbeux. - HttpCache (le reverse-proxy PHP de Symfony) est lui-même un
HttpKernelInterfacequi décore ton kernel. Tu peux empiler des kernels comme des poupées russes. Même chose pour leTraceableHttpKerneldu profiler.
Mental model du staff : ne pense pas "Symfony fait X", pense "quel listener, à quelle priority, sur quel event, fait X — et puis-je le voir, le remplacer, le court-circuiter ?". 90 % du debug de prod Symfony se résout avec cette question.
Tradeoffs structurants
| Décision | Option A | Option B | Comment trancher |
|---|---|---|---|
| Cross-cutting logic | Listener kernel | Middleware métier dans le controller | Listener si vraiment transverse (request-id, locale, tenant, audit). Sinon service appelé depuis le controller — un listener "métier" est un couplage caché. |
| Court-circuit | kernel.request listener (setResponse) | Controller qui early-return | Listener si la décision précède le routing (maintenance, IP-block, rate-limit global). Controller si la décision dépend de la logique métier. |
| Travail post-réponse | kernel.terminate | Message bus async (Messenger) | terminate = best-effort, non durable, perdu si le worker crash ou en mode FPM si le process meurt. Messenger = durable, retryable, observable. Pour de l'audit réglementaire ou un paiement → Messenger, jamais terminate. terminate est pour l'optimisation TTFB d'effets non critiques. |
| Transformation de la réponse | kernel.view (DTO → Response) | Controller qui construit la Response | kernel.view (façon API Platform) pour une API homogène à grande échelle. Response explicite pour <20 endpoints — moins de magie, plus de grep-ability. |
| Format d'erreur | kernel.exception centralisé | try/catch par controller | Toujours centralisé. Un try/catch HTTP dans un controller est un code smell : la frontière HTTP appartient au kernel, pas au métier. |
Failure modes que seul l'expérience révèle
- Le
terminatequi "marche en dev, perd des events en prod" — en CLI/serveur built-in/PHPUnit,terminateest synchrone donc fiable. En PHP-FPM, il ne tourne qu'aprèsfastcgi_finish_request(); si le pool FPM tue le process (timeout, OOM, restart), les listeners terminate ne tournent jamais. Symptôme classique : "10 % de nos events analytics manquent en prod, 0 % en staging". - Le state leak en worker mode (FrankenPHP/RoadRunner/Swoole) — le container est booté une fois pour des milliers de requêtes. Un service avec une propriété mutable (cache interne,
?User $currentUser) garde l'état de la requête précédente. La requête N+1 voit le user de la requête N → fuite de données entre utilisateurs, le pire bug de sécurité possible. Règle absolue : servicesreadonly/stateless, tout état request-scoped passe parRequestStack, jamais par une propriété ni unstatic. - L'exception dans un listener
kernel.exception— le kernel a un garde anti-boucle (flaghandleThrowable), mais la 2e exception masque la 1re. Tu vois "Internal Server Error" générique au lieu de la vraie cause. Toujourstry/catchdéfensif dans un exception listener. - Le sous-request qui ré-exécute un listener coûteux — chaque
render(controller(...))Twig, chaque ESI, chaqueforward()est unSUB_REQUEST. Un listener qui ne filtre pasisMainRequest()peut tourner 5-20× par page (DB hit, log, auth recheck). Symptôme : "notre listener X fait 14 requêtes SQL par page". - Le
_routelu trop tôt — un listener à priority > 32 (avantRouterListener) qui fait$request->attributes->get('_route')reçoitnull. Bug silencieux : lematch($route)tombe endefault. Toujours connaître ta position vs le Router.
Production : observabilité, perf, sécurité
- Observabilité : le bon endroit pour ouvrir/fermer une span de tracing (OpenTelemetry) est
kernel.request(priority très haute, start span) +kernel.terminate(end span, pour couvrir aussi le travail post-réponse). Lerequest_id/trace_idposé enkernel.requestdoit se retrouver dans chaque ligne de log (processor Monolog) et dans le header de réponse (corrélation client↔serveur↔Datadog). - Perf : le pipeline kernel lui-même coûte des microsecondes ; le vrai coût est dans les listeners. Audit avec le profiler (
Eventspanel) oudebug:event-dispatcher. Un listenerkernel.requestsur le hot path qui tape Redis/DB synchronement à chaque requête est un anti-pattern — cache-le agressivement (le tenant resolver du Scénario 1 cache 5 min, c'est délibéré). - Sécurité : la fenêtre de priority est une surface de sécurité. Un listener qui fait de l'authz à priority > 8 s'exécute avant le Firewall →
Security::getUser()renvoienull, et tu autorises tout le monde. À l'inverse,ValidateRequestListener(256) protège contre l'HTTP Host header injection avant tout le reste — ne le désactive jamais. Ettrusted_proxies/trusted_hostsdoivent être configurés sinon$request->getClientIp()etisSecure()sont spoofables derrière un load balancer.
🔁 Détails priorities des core listeners
Connaître les priorities built-in évite les surprises :
⚠️ Ne jamais hardcoder ces valeurs de mémoire. Elles varient selon la version et les bundles installés. La source de vérité est
php bin/console debug:event-dispatcher kernel.requestsur ton projet. Le tableau ci-dessous donne les ordres de grandeur stables (Symfony 6.4/7.x, full-stack standard) pour raisonner, pas pour copier.
| Event | Listener | Priority | Rôle |
|---|---|---|---|
kernel.request | ValidateRequestListener | 256 | Valide Host/IP (anti-host-injection) |
kernel.request | RouterListener | 32 | Match l'URL → pose _controller, _route |
kernel.request | LocaleListener | 16 | Détermine la locale |
kernel.request | FirewallListener | 8 | Authentifie l'utilisateur (security) |
kernel.controller | IsGrantedAttributeListener | varie | Vérifie #[IsGranted] |
kernel.controller_arguments | RequestPayloadValueResolver | — | Désérialise #[MapRequestPayload] |
kernel.view | SerializerViewListener (API Platform) | 0 | Sérialise le DTO retourné |
kernel.response | ProfilerListener | -100 | Collecte les données du profiler |
kernel.response | ResponseListener (Content-Type) | -1024 | Force le Content-Type |
kernel.exception | ErrorListener (Symfony default) | -128 | Convertit l'exception en Response |
kernel.terminate | SendEmailMessageListener (Mailer) | 0 | Flush la queue d'emails async |
Règle empirique : pour s'exécuter avant le Router (donc sans _route ni params encore disponibles), priority > 32. Pour s'exécuter après la Firewall (donc avec un user authentifié dans Security), priority < 8. C'est exactement la fenêtre ]8, 32[ où la plupart des listeners métier "post-auth, pre-controller" se logent.
Note historique :
ProfilerListenerest souvent décrit (à tort) comme un listenerkernel.requestà priority 1024. En réalité il collecte surkernel.response/kernel.terminate. Le vrai listenerkernel.requestà très haute priority estValidateRequestListener(256).ParamConverterListeneret la signature annotation@ParamConverteront été remplacés en 6.x par lesValueResolvernatifs (#[MapEntity],#[MapRequestPayload]).
🔗 Liens
- HttpKernel component
- Working with Kernel Events
- MicroKernelTrait
- FrankenPHP worker mode
- Fabien Potencier — "Create your own framework on top of the Symfony Components"
debug:event-dispatcher— voir les listeners enregistrés et leur priority
🪜 Approfondissement — flow détaillé par étape
Étape 1 : kernel.request
- C'est ici que le Firewall authentifie l'utilisateur (token resolver, badge système).
- Le Router match l'URL et set
_controller,_route, et tous les params route dans$request->attributes. - Le LocaleListener détermine la locale (depuis
_localeattribute, query, ou Accept-Language). - Un listener custom peut court-circuiter via
$event->setResponse(...): maintenance mode, redirect HTTPS, rate limiting.
Étape 2 : ControllerResolver::getController()
- Lit
$request->attributes->get('_controller'). - Format supporté :
App\Controller\FooController::bar,App\Controller\FooController(invokable), service ID, closure. - Si service ID, lookup dans le container ; sinon
new ClassName()(rare, déprécié).
Étape 3 : kernel.controller
- Le controller est résolu mais pas encore appelé.
- Utilisé par CSRF (legacy),
IsGranted(security),ParamConverter(legacy Doctrine). - Tu peux remplacer le controller via
$event->setController(...): utile pour mock en tests E2E.
Étape 4 : ArgumentResolver::getArguments()
- Chaque paramètre du controller passe dans la chaîne de
ValueResolverInterface. - Order matters via priority (
controller.argument_value_resolvertag). - Retourne
arraydes arguments dans l'ordre exact duMethod::getParameters().
Étape 5 : appel du controller
call_user_func_array($controller, $arguments).- Le retour peut être
Response(direct) ou un objet (déclenchekernel.view).
Étape 6 : kernel.view (si retour non-Response)
- Le
SerializerViewListener(API Platform) ou un listener custom convertit l'objet enResponse. - Si aucun listener ne pose une Response → exception "controller must return Response".
Étape 7 : kernel.response
- Last chance d'ajouter headers/cookies/CORS.
SaveSessionListenersave la session ici.WebDebugToolbarListenerinjecte le toolbar HTML.
Étape 8 : Response::send()
- Headers d'abord (
sendHeaders()), puis body (sendContent()). - Pour StreamedResponse,
sendContent()invoque la closure et flush par chunks.
Étape 9 : kernel.terminate
- Réponse déjà partie au client (via
fastcgi_finish_request()si dispo). - Slow tasks : email queuing, push notifications, analytics, log compaction.
🏋️ Exercices
Progression : implémenter → durcir pour la prod → casser puis réparer. Fais-les dans l'ordre.
Exercice 1 — Maintenance mode court-circuité (implémenter)
Objectif : un listener kernel.request qui renvoie un 503 avec header Retry-After quand le flag MAINTENANCE=1 est actif, sauf pour les IP whitelistées et la route health_check.
Indice/Solution : #[AsEventListener(KernelEvents::REQUEST, priority: 40)] (au-dessus du Router pour bloquer avant toute logique, mais lis le _route via getPathInfo() plutôt que _route qui n'existe pas encore). if (!$event->isMainRequest()) return;, check $_ENV/un FeatureFlag service, compare $event->getRequest()->getClientIp() à la whitelist (⚠️ configure trusted_proxies sinon l'IP est spoofable), puis $event->setResponse(new Response('', 503, ['Retry-After' => '120'])).
Exercice 2 — Argument value resolver #[CurrentTenant] (implémenter, niveau intermédiaire)
Objectif : un attribut #[CurrentTenant] qui, posé sur un argument de controller (public function __invoke(#[CurrentTenant] Tenant $tenant)), injecte le tenant résolu depuis le sous-domaine — sans passer par $request->attributes dans le controller.
Indice/Solution : implémente ValueResolverInterface::resolve(Request $request, ArgumentMetadata $argument): iterable. Vérifie $argument->getAttributesOfType(CurrentTenant::class) non vide, charge le Tenant (depuis l'attribut posé en kernel.request par ton tenant listener, ou résous-le ici avec cache), puis yield $tenant. Tag automatique via autoconfiguration ; règle la priority pour passer avant le DefaultValueResolver.
Exercice 3 — Audit terminate → migration vers Messenger (production-grade)
Objectif : prends l'AcprAuditListener de la section end-to-end. Démontre par un test qu'en cas de kill du process FPM l'audit est perdu, puis refactore pour rendre l'audit durable via Messenger tout en gardant le TTFB optimal.
Indice/Solution : en kernel.terminate, ne fais plus $this->audit->record(...) direct mais $this->bus->dispatch(new RecordAuditMessage(...)) sur un transport async (Doctrine/Redis). Le dispatch est quasi-instantané, le handler tourne hors requête, et tu gagnes retry + dead-letter. Discute le tradeoff : terminate reste utile pour disptacher vite sans bloquer, mais la durabilité vient de Messenger, pas de terminate. Test : $kernel->terminate() jamais appelé ⇒ avec Messenger le message est déjà en queue.
Exercice 4 — Exception listener RFC 7807 typé (production-grade)
Objectif : un seul kernel.exception listener qui mappe ValidationFailedException, AccessDeniedHttpException, HttpExceptionInterface et \Throwable générique vers application/problem+json, avec trace_id, jamais de stack trace en prod, mais détaillé en dev.
Indice/Solution : #[AsEventListener(KernelEvents::EXCEPTION, priority: -64)] (après l'ErrorListener core ? non — avant, sinon il a déjà produit une Response HTML ; pose-toi à priority haute genre 64 et setResponse tôt). Construis le body Problem Details (type, title, status, detail, instance, trace_id). if ($kernel->isDebug()) ajoute exception_class + trace. ⚠️ try/catch défensif tout le listener pour éviter le masquage d'exception. Restreins au Accept: application/json pour ne pas casser le rendu HTML.
Exercice 5 — Casser puis réparer : le state leak en worker mode
Objectif : reproduis une fuite de données cross-requête en mode worker, observe-la, puis répare-la.
Indice/Solution : crée un service CurrentUserHolder avec une propriété mutable private ?User $user, settée en kernel.request depuis Security. Lance en FrankenPHP worker mode (ou simule : deux handle() successifs sur le même container sans reset). Requête A authentifie Alice ; requête B (non authentifiée) lit $holder->getUser() → Alice. Fix : supprime l'état du service, lis toujours via Security/RequestStack (eux sont reset par le kernel à chaque requête). Bonus : ajoute un kernel.terminate/reset listener qui unset l'état si tu dois absolument en garder.
Exercice 6 — Mesurer le pipeline (break-then-fix, perf)
Objectif : un listener qui tape Redis synchronement en kernel.request plombe le P95. Mesure-le, puis optimise.
Indice/Solution : ajoute un listener qui fait un GET Redis bloquant à chaque requête, mesure le P95 sous charge (wrk/k6) et lis le panel Performance/Events du profiler. Fix : cache local in-process (avec invalidation TTL court), ou déplace le travail là où il appartient (cache HTTP, ou lazy dans le service consommateur). Compare les deux flamegraphs avant/après et montre la réduction du temps passé dans kernel.request.
🎤 En entretien
Q : Quelle est la différence entre kernel.terminate et un message Messenger async pour du travail post-réponse ? R : terminate est best-effort et non durable — il tourne dans le même process après fastcgi_finish_request(), donc perdu si le process meurt (timeout FPM, OOM, crash worker) et ne tourne même pas du tout sans FPM/FrankenPHP. Messenger est durable, retryable, observable, hors-process. terminate pour optimiser le TTFB d'effets non critiques ; Messenger dès que la perte du travail a un coût (audit, paiement, email transactionnel).
Q : Un listener d'authz que tu poses à priority 40 sur kernel.request laisse tout le monde passer. Pourquoi ? R : Le FirewallListener tourne à priority 8 ; à 40 ton listener s'exécute avant lui, donc Security::getUser() renvoie null et tes checks de rôle échouent ouvert. Toute logique post-auth doit être à priority < 8, ou mieux faite en kernel.controller/via #[IsGranted]. Corollaire : à priority > 32 tu n'as pas non plus _route ni les params, car le Router (32) n'a pas encore tourné.
Q : Comment garantis-tu qu'un service ne fuit pas d'état entre requêtes en mode worker (FrankenPHP/RoadRunner) ? R : Le container est booté une seule fois et réutilisé. Donc : services stateless/readonly, aucun static ni propriété mutable request-scoped, tout état de requête lu via RequestStack/Security (réinitialisés par le kernel à chaque handle()). Le risque sinon est une fuite de données entre utilisateurs — pas un bug de perf, un bug de sécurité critique. On le détecte avec un test qui rejoue deux requêtes sur le même container booté.
Q : Pourquoi dit-on que HttpKernel est "juste une fonction" et qu'est-ce que ça permet ? R : Parce que HttpKernelInterface::handle(Request, type, catch): Response est une signature pure et composable. Ça permet de décorer un kernel par un autre (le HttpCache reverse-proxy et le TraceableHttpKernel du profiler sont des kernels qui en wrappent un autre), d'empiler les comportements comme des middlewares, et de raisonner sur tout le framework comme un pipeline Request → Response observable, court-circuitable et remplaçable événement par événement.