Photo par Nathan Dumlao sur Pexels
Mon EventSubscriber masquait les erreurs, voici pourquoi
Un ticket Jira est apparu : « Il y a un bug étrange qui empêche les utilisateurs d'accéder à une page à ce moment-là de la journée. » Les logs indiquaient plusieurs fois : « [ce jour T cette heure] request.ERROR: Uncaught PHP Exception Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException: "Access denied to that resource." at WhitelistSubscriber.php line 99 »
Je n'avais aucune idée au début… 😅 Voici comment j'ai compris.
La configuration
J'avais un EventSubscriber qui vérifiait l'accès aux pages basé sur une liste blanche de routes. C'était du code legacy — le refactoriser n'était pas à l'ordre du jour à ce moment-là.
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class WhitelistRouteSubscriber implements EventSubscriberInterface
{
private const WHITELISTED_ROUTES = [
'app_login',
'app_homepage',
'app_healthcheck',
];
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$route = $request->attributes->get('_route');
// Allow whitelisted routes
if (in_array($route, self::WHITELISTED_ROUTES, true)) {
return;
}
// Deny access for non-whitelisted routes
throw new AccessDeniedHttpException('Route not whitelisted');
}
} Objectif : Bloquer toutes les routes sauf la liste blanche. Simple, non ?
Le problème
Les logs montraient des AccessDeniedHttpException sur des routes que je savais être dans la liste blanche. Premier geste classique : mettre un dump() dans le subscriber pour voir ce qui arrivait.
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$route = $request->attributes->get('_route');
dump($route); // 🔍 Voyons ce qui se passe
// ...
} Première découverte surprenante : le subscriber était appelé deux fois pour une seule requête. Le premier appel avait la route attendue, le second avait $route = null.
Question évidente : pourquoi _route est-il null ?
J'ai creusé plus loin avec dump($request->getPathInfo()) pour voir quelle URL était traitée lors du second appel :
// 1er appel
dump($request->getPathInfo()); // "/foo"
// 2e appel
dump($request->getPathInfo()); // "/foo" ← identique. Attends, quoi ? Même URL, appelée deux fois. Cela n'avait aucun sens — si c'était la même requête, pourquoi _route était-il null la deuxième fois ? Je tournais en rond.
J'ai donc dumpé l'objet $event complet pour avoir plus de contexte, et j'ai réduit le champ à _controller dans les attributs de la requête :
dump($request->attributes->get('_controller'));
// "Symfony\Component\HttpKernel\Controller\ErrorController" Voilà. _controller ne pointait pas vers mon code du tout. Symfony avait forgé une toute nouvelle requête vers son propre ErrorController, réutilisant l'URL d'origine — ce qui explique pourquoi getPathInfo() était si trompeur — mais en contournant complètement le routeur. C'est pourquoi _route était null.
Cause racine
Le flux réel était :
Requête → /foo
└── WhitelistSubscriber (1er appel) → _route = 'app_foo' ✅ Accès accordé
└── Controller → lève RealException 💥
└── Symfony l'attrape
└── Sous-requête → ErrorController (contourne le routeur, pas de _route)
└── WhitelistSubscriber (2e appel) → _route = null ❌ AccessDenied levé
└── RealException est maintenant silencieuse 🔇 Le piège : l'AccessDeniedHttpException du subscriber masquait complètement l'exception originale — celle qui contenait réellement les informations de débogage utiles.
Lorsqu'une exception est levée, le HttpKernel de Symfony distribue un événement KernelEvents::EXCEPTION, puis délègue le rendu de l'erreur à ErrorController via une sous-requête interne. Cette sous-requête réutilise l'URL d'origine — ce qui explique pourquoi getPathInfo() était trompeur — mais elle contourne complètement la couche de routage, laissant _route à null.
La solution
Vérifiez si la requête est la requête principale (pas une sous-requête) :
<?php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class WhitelistRouteSubscriber implements EventSubscriberInterface
{
private const WHITELISTED_ROUTES = [
'app_login',
'app_homepage',
'app_healthcheck',
];
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 0],
];
}
public function onKernelRequest(RequestEvent $event): void
{
// Skip sub-requests (like error handling)
if (!$event->isMainRequest()) {
return;
}
$request = $event->getRequest();
$route = $request->attributes->get('_route');
// Allow whitelisted routes
if (in_array($route, self::WHITELISTED_ROUTES, true)) {
return;
}
// Deny access for non-whitelisted routes
throw new AccessDeniedHttpException('Route not whitelisted');
}
} isMainRequest() retourne false pour toute sous-requête interne — gestion d'erreurs, fragments ESI, hinclude — donc votre logique ne s'exécute que sur les vraies requêtes distribuées par le routeur.
Note :
isMainRequest()a remplacé l'ancienisMasterRequest()dans Symfony 5.3. Si vous êtes sur une version plus ancienne, utilisezisMasterRequest()à la place.
Conclusion
Chaque fois que votre subscriber fait quelque chose de destructeur — lever une exception, rediriger, définir une réponse — demandez-vous : que se passe-t-il quand Symfony appelle ceci sur une sous-requête ?
Les sous-requêtes sont omniprésentes dans Symfony : gestion d'erreurs, ESI, fragments. Elles n'ont pas le même contexte qu'une requête principale, et votre subscriber ne connaît pas la différence à moins que vous ne lui disiez.
isMainRequest() est cette vérification. Faites-en un réflexe. 🎉