Skip to content

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+)

php
// 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']);
};
php
// 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 :

php
// 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

  1. 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.
  2. Sub-requests$kernel->handle($subRequest, HttpKernelInterface::SUB_REQUEST) pour render(controller()) Twig ; n'émet pas kernel.terminate, certaines features (firewall main) sont skip.
  3. kernel.terminate pour 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. Requiert fastcgi_finish_request() ou PHP-FPM pour le vrai effet.
  4. Exception listener pour API uniforme — un seul kernel.exception listener qui convertit HttpException, ValidationFailedException, etc. en JSON RFC 7807 (Problem Details).
  5. kernel.view pour controllers qui retournent des DTOs — au lieu de renvoyer Response, le controller retourne un objet, et un listener le serialize en JSON via le Serializer. C'est ce que fait API Platform.
  6. FrontController uniquepublic/index.php est 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 @Route toujours OK mais déjà découragées ; attributs PHP 8 #[Route] disponibles. EventSubscriberInterface reste la norme pour les listeners.
  • 6.0 : suppression des annotations (lib doctrine/annotations devient optionnelle), attributs PHP partout. Apparition de #[AsEventListener] (Symfony 6.1) → plus besoin d'implémenter EventSubscriberInterface pour un listener simple.
  • 6.3 : #[AsController] (déjà là), amélioration de l'autowiring des ArgumentResolverInterface.
  • 6.4 LTS : stable, base recommandée pour migration vers 7.x. kernel.terminate events bénéficient du RoadRunner / FrankenPHP worker mode (attention au state leak entre requêtes).
  • 7.0 : suppression de EventDispatcherInterface::dispatch() legacy signature, supression de plusieurs deprecated Kernel methods. 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

  1. Confondre main request et sub-request — un listener qui ne check pas $event->isMainRequest() peut s'exécuter 10× par page (chaque render(controller()) Twig est une sub-request).
  2. Modifier la Response dans kernel.terminate — trop tard, elle est déjà envoyée au client. Utiliser kernel.response pour ajouter headers/cookies.
  3. Throw dans kernel.terminate — l'exception est swallowée silencieusement (le client a déjà sa réponse). Logger explicitement, sinon bugs invisibles.
  4. Priority hell — un listener à kernel.request priority 0 vs Firewall (priority 8). Lister avec debug:event-dispatcher kernel.request pour voir l'ordre réel.
  5. Worker mode (FrankenPHP/RoadRunner) + state global — variables static, $_GET/$_POST, container booté une seule fois : tout state non-reset = leak entre requêtes. Toujours utiliser RequestStack, jamais $_SERVER.
  6. Oublier fastcgi_finish_requestkernel.terminate ne bloque pas le client uniquement si PHP-FPM ou équivalent ; en CLI ou avec built-in server, c'est sync.
  7. kernel.exception qui 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.
  8. MicroKernel sans cache dir — pour un script CLI one-shot, oublier de définir getCacheDir() lance des warmups inutiles à chaque run.

🧪 Testing

php
// 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 :

php
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.

php
// 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'),
        );
    }
}
php
// 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.request custom : 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-dispatcher liste 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 HttpKernelInterface qui décore ton kernel. Tu peux empiler des kernels comme des poupées russes. Même chose pour le TraceableHttpKernel du 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écisionOption AOption BComment trancher
Cross-cutting logicListener kernelMiddleware métier dans le controllerListener si vraiment transverse (request-id, locale, tenant, audit). Sinon service appelé depuis le controller — un listener "métier" est un couplage caché.
Court-circuitkernel.request listener (setResponse)Controller qui early-returnListener 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éponsekernel.terminateMessage 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éponsekernel.view (DTO → Response)Controller qui construit la Responsekernel.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'erreurkernel.exception centralisétry/catch par controllerToujours 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

  1. Le terminate qui "marche en dev, perd des events en prod" — en CLI/serveur built-in/PHPUnit, terminate est synchrone donc fiable. En PHP-FPM, il ne tourne qu'après fastcgi_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".
  2. 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 : services readonly/stateless, tout état request-scoped passe par RequestStack, jamais par une propriété ni un static.
  3. L'exception dans un listener kernel.exception — le kernel a un garde anti-boucle (flag handleThrowable), mais la 2e exception masque la 1re. Tu vois "Internal Server Error" générique au lieu de la vraie cause. Toujours try/catch défensif dans un exception listener.
  4. Le sous-request qui ré-exécute un listener coûteux — chaque render(controller(...)) Twig, chaque ESI, chaque forward() est un SUB_REQUEST. Un listener qui ne filtre pas isMainRequest() peut tourner 5-20× par page (DB hit, log, auth recheck). Symptôme : "notre listener X fait 14 requêtes SQL par page".
  5. Le _route lu trop tôt — un listener à priority > 32 (avant RouterListener) qui fait $request->attributes->get('_route') reçoit null. Bug silencieux : le match($route) tombe en default. 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). Le request_id/trace_id posé en kernel.request doit 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 (Events panel) ou debug:event-dispatcher. Un listener kernel.request sur 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() renvoie null, 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. Et trusted_proxies/trusted_hosts doivent être configurés sinon $request->getClientIp() et isSecure() 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.request sur 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.

EventListenerPriorityRôle
kernel.requestValidateRequestListener256Valide Host/IP (anti-host-injection)
kernel.requestRouterListener32Match l'URL → pose _controller, _route
kernel.requestLocaleListener16Détermine la locale
kernel.requestFirewallListener8Authentifie l'utilisateur (security)
kernel.controllerIsGrantedAttributeListenervarieVérifie #[IsGranted]
kernel.controller_argumentsRequestPayloadValueResolverDésérialise #[MapRequestPayload]
kernel.viewSerializerViewListener (API Platform)0Sérialise le DTO retourné
kernel.responseProfilerListener-100Collecte les données du profiler
kernel.responseResponseListener (Content-Type)-1024Force le Content-Type
kernel.exceptionErrorListener (Symfony default)-128Convertit l'exception en Response
kernel.terminateSendEmailMessageListener (Mailer)0Flush 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 : ProfilerListener est souvent décrit (à tort) comme un listener kernel.request à priority 1024. En réalité il collecte sur kernel.response/kernel.terminate. Le vrai listener kernel.request à très haute priority est ValidateRequestListener (256). ParamConverterListener et la signature annotation @ParamConverter ont été remplacés en 6.x par les ValueResolver natifs (#[MapEntity], #[MapRequestPayload]).

🔗 Liens

🪜 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 _locale attribute, 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_resolver tag).
  • Retourne array des arguments dans l'ordre exact du Method::getParameters().

Étape 5 : appel du controller

  • call_user_func_array($controller, $arguments).
  • Le retour peut être Response (direct) ou un objet (déclenche kernel.view).

Étape 6 : kernel.view (si retour non-Response)

  • Le SerializerViewListener (API Platform) ou un listener custom convertit l'objet en Response.
  • Si aucun listener ne pose une Response → exception "controller must return Response".

Étape 7 : kernel.response

  • Last chance d'ajouter headers/cookies/CORS.
  • SaveSessionListener save la session ici.
  • WebDebugToolbarListener injecte 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.

Bibliothèque tech perso — Achref