Routing — attributes, conditions, i18n, expression language
TL;DR — Le router Symfony compile un tableau de routes en un PHP
Matcherultra-optimisé (regex unique, dump statique). Les attributes#[Route]sont la norme depuis 6.0. Tu peux contraindre par method, host, scheme, condition (expression language), requirements (regex), defaults, priority, et générer des URLs localisées automatiquement.debug:router+router:matchsont tes meilleurs amis.
🧠 Mental model — ASCII diagram + analogie
Request URI: /fr/articles/42/comments
│
▼
┌──────────────────────────────────────────────────┐
│ RouterListener (kernel.request, priority 32) │
│ - calls UrlMatcher::match("/fr/articles/...") │
│ │
│ Compiled matcher (var/cache/.../url_matching_*) │
│ regex: "{^(?:(?P<_locale>fr|en)/articles/...)}"│
│ ranked by priority, then declaration order │
│ │
│ MATCH found: │
│ _route = 'article_comments' │
│ _locale = 'fr' │
│ id = 42 │
│ _controller = 'App\Controller\Article::show' │
└──────────────────────────────────────────────────┘
│
▼
Request::attributes->set('_route', 'article_comments')
Request::attributes->set('_controller', 'App\Controller\Article::show')
Request::attributes->set('id', 42)
│
▼
ControllerResolver picks $_controller from attributesAnalogie : le router est un standardiste téléphonique très efficace. À chaque appel (Request URI) il consulte son annuaire compilé (regex géant) et te redirige vers le bon poste (controller). L'annuaire est imprimé une fois (cache warmup) ; rappeler 1000× est gratuit.
🔬 Comment le matcher est compilé (le vrai modèle de coût)
Le point que 90% des devs Symfony ne savent pas : UrlMatcher::match() n'itère pas sur tes routes une par une. Le CompiledUrlMatcherDumper produit un fichier PHP statique (var/cache/<env>/url_matching_routes.php) qui contient :
- Une regex unique combinée (
combinede toutes les routes "statiques-compatibles") avec des groupes nommés(?P<MARK>...). Une seule passepreg_matchte dit quelle route a matché via le marqueur capturé. C'est O(taille de la regex), pas O(nombre de routes). - Un tableau statique pour les routes purement littérales (
/health,/login) — lookup hash O(1), zéro regex. - Une liste "dynamique" pour les routes avec
condition,hostvariable, oumethodsmultiples qui exigent une seconde vérification après le match regex.
/fr/articles/42
│
▼
1) static map lookup ─── hit? ──▶ route trouvée (O(1), cas /health, /login)
│ miss
▼
2) preg_match(/regex combinée/) ─── capture (?P<MARK>) = id de route
│
▼
3) post-checks de la route matchée :
- methods autorisées ? → sinon 405 MethodNotAllowed (pas 404 !)
- schemes (http/https) ? → sinon redirection 308
- condition (ExprLang) ? → false ⇒ on continue à la route suivante
- requirements regex ? → déjà encodés dans la regex combinéeConséquence pratique #1 : avoir 50 ou 5000 routes change peu la latence de match d'une requête (la regex grossit en largeur, pas le nombre d'appels preg_match). Le coût réel se déplace vers le warmup du cache et l'empreinte mémoire du fichier compilé.
Conséquence pratique #2 : condition est le seul critère non encodable dans la regex → il est évalué en PHP, à chaque requête, après le match. C'est pourquoi une condition lourde (appel DB, I/O) est un anti-pattern : elle s'exécute sur le chemin chaud, sans cache, pour potentiellement rejeter la route et continuer.
Conséquence pratique #3 : 405 Method Not Allowed vs 404 Not Found est décidé ici. Si le path matche mais pas la méthode, Symfony lève MethodNotAllowedException (→ 405 + header Allow). Beaucoup de devs croient à tort qu'un mauvais verbe donne un 404.
Tradeoffs — où placer la contrainte
| Mécanisme | Coût runtime | Encodé dans la regex compilée ? | Quand l'utiliser |
|---|---|---|---|
requirements (regex sur param) | quasi nul | ✅ oui | Toujours pour valider la forme d'un param (\d+, slug, locale) |
methods | très faible (post-check) | partiel | Discrimination REST GET/POST/PUT/DELETE |
schemes | très faible | partiel | Forcer https (redirection auto 308) |
host (statique) | faible | ✅ oui | Multi-tenant / multi-marque par domaine |
host (variable {tld}) | faible | partiel | Domaines paramétrés |
condition (ExpressionLanguage) | moyen, à chaque req | ❌ non | Header/feature-flag uniquement, jamais d'I/O |
| Logique dans le controller | dépend | n/a | Dès que c'est de la logique métier |
Règle staff : pousse la contrainte le plus haut possible dans cette table tant qu'elle reste déclarative et gratuite. Descends vers condition puis le controller seulement quand le critère ne peut pas être exprimé par une regex de path/host.
🛠️ Code minimal — attribute routing avancé
// src/Controller/ArticleController.php
<?php
namespace App\Controller;
use App\Entity\Article;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/{_locale}/articles', requirements: ['_locale' => 'fr|en'], name: 'article_')]
final class ArticleController extends AbstractController
{
#[Route('', name: 'list', methods: ['GET'])]
public function list(): Response
{
return $this->render('article/list.html.twig');
}
#[Route(
'/{id}',
name: 'show',
requirements: ['id' => '\d+'],
methods: ['GET'],
priority: 10,
)]
public function show(int $id): Response
{
return $this->render('article/show.html.twig', ['id' => $id]);
}
#[Route(
'/{slug}',
name: 'show_by_slug',
requirements: ['slug' => '[a-z0-9-]+'],
methods: ['GET'],
priority: 0,
)]
public function showBySlug(string $slug): Response
{
return $this->render('article/show.html.twig', ['slug' => $slug]);
}
#[Route(
'/{id}/edit',
name: 'edit',
requirements: ['id' => '\d+'],
methods: ['GET', 'POST'],
condition: "request.headers.get('User-Agent') matches '/Mozilla/i'",
)]
public function edit(Article $article): Response
{
return $this->render('article/edit.html.twig', ['article' => $article]);
}
#[Route(
path: [
'fr' => '/api/articles/{id}',
'en' => '/api/articles/{id}',
],
name: 'api_show',
host: 'api.example.com',
schemes: ['https'],
methods: ['GET'],
format: 'json',
)]
public function apiShow(int $id): JsonResponse
{
return $this->json(['id' => $id]);
}
}YAML équivalent (utile pour config dynamique) :
# config/routes.yaml
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
# Routes externes (non-controller)
homepage:
path: /
controller: App\Controller\HomeController::__invoke
api_health:
path: /health
methods: GET
defaults:
_controller: 'App\Controller\HealthController'
_format: json
schemes: ['https']
condition: "context.getMethod() == 'GET'"Générer une URL :
// Dans un controller (AbstractController)
$url = $this->generateUrl('article_show', ['id' => 42, '_locale' => 'fr']);
// "/fr/articles/42"
// Avec ref absolue
$url = $this->generateUrl('article_show', ['id' => 42], UrlGeneratorInterface::ABSOLUTE_URL);
// "https://example.com/fr/articles/42"En Twig (les deux helpers ; path = relatif, url = absolu) :
{{ path('article_show', { id: 42 }) }}
{{ url('article_show', { id: 42 }) }}🎯 Patterns courants
- Prefix + name prefix au niveau classe —
#[Route('/admin', name: 'admin_')]sur la classe, les méthodes héritent du préfixe URI et name. Évite la duplication. - Priority pour résoudre les conflits —
/{slug}vs/{id}qui matchent tous deux/foo: priority décide. Plus haut = matched en premier. - Localized routes — passer
path: ['fr' => '/articles', 'en' => '/posts']génère 2 routes distinctes, sélection automatique selon_locale. - Conditions expression language —
condition: "request.headers.get('X-Api-Version') == 'v2'"pour A/B testing, versioning header-based, feature flags. - Route requirements regex —
requirements: ['id' => '\d+', 'lang' => 'fr|en|de']empêche match sur des valeurs invalides → 404 propre, jamais d'exception runtime. #[Route(env: 'dev')](6.2+) — route activée uniquement en envdev(utile pour debug endpoints, fixtures, etc.). En prod la route n'existe simplement pas → meilleure sécurité.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
- 5.4 : annotations
@RouteET attributes#[Route]co-existent. NamespaceSymfony\Component\Routing\Annotation\Route(legacy) ETSymfony\Component\Routing\Attribute\Route(nouveau). - 6.0 : annotations supprimées dans les makers/recipes. Le namespace
Annotation\Routereste mais aliasé versAttribute\Route.host,schemes,methods,conditiontous configurables via attribute. - 6.2 :
#[Route(env: 'dev')]pour env-conditional routes. Path localisé via arraypath: ['fr' => ..., 'en' => ...]. - 6.3 :
#[Route(format: 'json')]shortcut pourdefaults: { _format: json }.#[Route(stateless: true)]shortcut. - 6.4 LTS : namespace
Symfony\Component\Routing\Annotation\Routedeprecated → utiliserAttribute\Route. Génération de routes avecUrlGeneratorInterface::ABSOLUTE_URLthread-safe en worker mode. - 7.0 : suppression définitive du namespace
Annotation\Route. Tu dois utiliserAttribute\Route. Fail au boot sinon. - 7.1+ : amélioration du caching des matchers pour gros volumes de routes (>1000).
UrlMatcherretourne des objets typés au lieu d'arrays purs (préparation typed routes).
⚠️ Pitfalls
- Trailing slash mismatch —
/articleset/articles/sont 2 routes différentes. Configure soit l'une soit l'autre, ou utiliseframework.router.utf8: true+ redirect via listener. - Order matters en YAML — la première route matchée gagne ; en attributes c'est
priorityqui décide. Mixer les deux = ordre indéterminé, utilise priority partout. requirementsignoré — si tu metsrequirements: ['id' => 'd+'](oublie le\), ça matched,dd, etc., pas des chiffres. Tester avecrouter:match.- Expression
conditionnon-cacheable — l'expression est évaluée à chaque requête. Évite des conditions lourdes (DB call) ; reste surrequest.*,context.*. _localenon-restreint — sansrequirements: ['_locale' => 'fr|en'], un attaquant peut passer_locale=../etc/passwdet casser des choses. Toujours whitelister.- URL generation context manquant — en CLI (
bin/console),RequestContextn'a pas de host par défaut → URLs absolues cassées. Configurerframework.router.default_uri: 'https://example.com'. - Routes deprecated mais utilisées — pas de mécanisme natif pour deprecate une route. Stratégie : listener qui ajoute un header
Deprecation:+ log. - Wildcard
{path}greedy —/{path}match TOUT, y compris des routes plus spécifiques déclarées après. Mettrepriority: -100ou utiliserrequirements: ['path' => '.+']consciemment.
🧪 Testing
// tests/Routing/RouteMatchTest.php
<?php
namespace App\Tests\Routing;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Routing\RouterInterface;
final class RouteMatchTest extends KernelTestCase
{
public function testArticleShowMatches(): void
{
self::bootKernel();
/** @var RouterInterface $router */
$router = static::getContainer()->get('router');
$match = $router->match('/fr/articles/42');
self::assertSame('article_show', $match['_route']);
self::assertSame('42', $match['id']);
self::assertSame('fr', $match['_locale']);
}
public function testInvalidIdReturns404(): void
{
self::bootKernel();
$router = static::getContainer()->get('router');
$this->expectException(\Symfony\Component\Routing\Exception\ResourceNotFoundException::class);
$router->match('/fr/articles/abc');
}
public function testUrlGeneration(): void
{
self::bootKernel();
$router = static::getContainer()->get('router');
$url = $router->generate('article_show', ['id' => 42, '_locale' => 'fr']);
self::assertSame('/fr/articles/42', $url);
}
}Functional test via WebTestCase :
public function testGetArticle(): void
{
$client = static::createClient();
$client->request('GET', '/fr/articles/42');
self::assertResponseIsSuccessful();
self::assertRouteSame('article_show', ['id' => '42']);
}Debug CLI essentiel :
php bin/console debug:router
php bin/console debug:router article_show
php bin/console router:match /fr/articles/42 --method=GET
php bin/console router:match /fr/articles/42 --method=GET --scheme=https --host=api.example.com🎬 Cas d'usage concrets
Scénario 1 — E-commerce mode (Sandro/Maje-like) : routes localisées FR/ES/IT/DE/EN
Contexte : marque de prêt-à-porter avec 5 boutiques en ligne. Les URLs doivent être SEO-friendly localisées : /fr/femme/robes/lin, /es/mujer/vestidos/lino, /it/donna/vestiti/lino. Pas un simple préfixe : les slugs eux-mêmes sont traduits, pour le ranking Google par marché.
L'équipe utilise #[Route(['fr' => '/{categorie}/{sous_categorie}', 'es' => '/{categoria}/{sub_categoria}', ...])] avec la nouvelle syntaxe localisée. Un LocaleListener priority 17 (juste après le LocaleListener Symfony default) résout le domaine boutique.fr / boutique.es en _locale request attribute. Le UrlGenerator injecté dans les services produit automatiquement la bonne URL pour la locale courante quand on génère un lien dans un email transactionnel.
Mesures : 30% de trafic organique en plus sur le marché italien en 6 mois, le crawler Google indexe correctement les pages par locale grâce au routing strictement séparé.
Scénario 2 — Banque (Crédit Agricole) : API versioning v1/v2 cohabitantes via host + path
Contexte : la DSI banque expose une API mobile à l'app du même nom (millions d'utilisateurs). L'app mobile v2 (sortie en 2025) consomme https://api.ca.fr/v2/... ; les anciens devices restent sur v1. Migration "big bang" impossible — il faut faire cohabiter les deux pendant 18 mois.
Solution : deux préfixes de routes via #[Route('/v1')] et #[Route('/v2')] sur les controllers. Un route_loader custom annote chaque endpoint avec un deprecated_at qui devient un header de réponse Deprecation (RFC 8594). Les controllers v1 héritent du namespace App\Api\V1, les v2 du namespace App\Api\V2. Pour les endpoints identiques entre versions, un trait SharedAccountActions est inclus dans les deux controllers — on garde le code DRY sans coupler les versions.
Une condition request.headers.get('X-Mobile-Build') >= 2400 active certaines routes "feature-flagged" seulement pour les builds récents — utile pour rollout progressif d'une feature côté serveur.
Scénario 3 — Immo (SeLoger-like) : agence interne + portail public, hosts différents
Contexte : éditeur immobilier qui fournit (1) un portail public selocer.fr pour les acheteurs/locataires, (2) un dashboard agence pro.selocer.fr pour les négociateurs, (3) une mini-app syndic syndic.selocer.fr pour la gestion locative. Tout dans la même Symfony app (équipe de 25 devs, économies opérationnelles).
Chaque section a son fichier de routes scopé par host :
#[Route(host: 'selocer.fr')] // portail public
final class PublicSearchController { ... }
#[Route(host: 'pro.selocer.fr', name: 'pro_')] // agence
final class AgencyDashboardController { ... }Les controllers du portail public et de l'agence ne se voient jamais. Un firewall différent s'applique selon le host (anonymous + JWT vs OAuth interne). Les URLs générées dans Twig utilisent automatiquement le bon host grâce au RequestContext configuré par environnement.
Bénéfice : un seul codebase, un seul Postgres, des marques différentes côté URL, et la sécurité scopée naturellement par le routing.
🛠️ Exemple end-to-end
Use case : portail immobilier multi-fonctions. Routes localisées FR/EN, host-aware pour public vs pro, expression language pour rate-limiter les recherches anonymes, et un RouteParameterResolver custom qui transforme {biensRef} en Bien entity.
// src/Routing/BienRefValueResolver.php
<?php
declare(strict_types=1);
namespace App\Routing;
use App\Catalog\Repository\BienRepository;
use App\Catalog\Entity\Bien;
use Symfony\Component\HttpFoundation\Exception\NotFoundHttpException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
final readonly class BienRefValueResolver implements ValueResolverInterface
{
public function __construct(private BienRepository $biens) {}
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
if ($argument->getType() !== Bien::class) {
return [];
}
$ref = $request->attributes->get($argument->getName());
if (!is_string($ref)) {
return [];
}
$bien = $this->biens->findByPublicRef($ref);
if ($bien === null) {
throw new NotFoundHttpException("Bien {$ref} not found");
}
return [$bien];
}
}// src/Controller/Public/BienDetailController.php
<?php
declare(strict_types=1);
namespace App\Controller\Public;
use App\Catalog\Entity\Bien;
use App\Catalog\Service\BienPresenter;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
enum BienType: string
{
case Vente = 'vente';
case Location = 'location';
public function slug(string $locale): string
{
return match ([$this, $locale]) {
[self::Vente, 'fr'] => 'a-vendre',
[self::Vente, 'en'] => 'for-sale',
[self::Location, 'fr'] => 'a-louer',
[self::Location, 'en'] => 'for-rent',
default => $this->value,
};
}
}
#[Route(host: '{tld}', requirements: ['tld' => 'selocer\.fr|selocer\.com'])]
final class BienDetailController extends AbstractController
{
#[Route(
path: [
'fr' => '/biens/a-vendre/{ville}/{biensRef}',
'en' => '/properties/for-sale/{ville}/{biensRef}',
],
name: 'public_bien_sale_detail',
requirements: ['biensRef' => '[A-Z]{2}\d{6}', 'ville' => '[a-z0-9-]+'],
methods: ['GET'],
condition: "request.headers.get('User-Agent') not matches '/badbot/i'",
)]
public function detail(Bien $biensRef, BienPresenter $presenter): Response
{
return $this->render('bien/detail.html.twig', [
'bien' => $presenter->forPublicView($biensRef),
]);
}
}// src/Controller/Pro/AgencyBiensController.php
<?php
declare(strict_types=1);
namespace App\Controller\Pro;
use App\Catalog\Service\AgencyBiensReader;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route(host: 'pro.selocer.fr')]
#[IsGranted('ROLE_NEGOCIATEUR')]
final class AgencyBiensController extends AbstractController
{
public function __construct(private readonly AgencyBiensReader $reader) {}
#[Route('/biens', name: 'pro_biens_list', methods: ['GET'])]
public function list(): Response
{
return $this->render('pro/biens/list.html.twig', [
'biens' => $this->reader->forCurrentAgency(),
]);
}
}Le flow : selocer.fr/biens/a-vendre/paris-15/PA123456 matche public_bien_sale_detail (locale fr), BienRefValueResolver charge l'entité, le controller render. pro.selocer.fr/biens matche un autre controller, requiert ROLE_NEGOCIATEUR via firewall. Le condition filtre les bots ; un bad-bot reçoit 404 sans même charger le controller. Les URLs générées par path('public_bien_sale_detail', ...) produisent automatiquement l'URL localisée selon _locale.
📈 Production — perf, sécurité, observabilité
Perf / warmup
- Le matcher compilé est régénéré au cache:clear / cache:warmup, pas à chaud. En déploiement, lance
php bin/console cache:warmupavant de basculer le trafic, sinon la première requête de chaque worker paie la compilation (latence p999 visible). - Au-delà de ~2000 routes, surveille la taille de
url_matching_routes.phpet le temps de warmup. Symfony 7.1+ a amélioré le caching pour les gros volumes, mais la vraie cure est de réduire le nombre de routes : préfère un param{type}avecrequirementsenum plutôt que N routes quasi identiques. - En runtime longue durée (FrankenPHP, RoadRunner, Swoole), le
RequestContextest partagé entre requêtes dans un worker. Vérifie que ton code ne mute pas le context global ($context->setHost(...)) sans le restaurer — fuite de host d'une requête à l'autre.
Sécurité
- Whitelist
_localesystématiquement (requirements: ['_locale' => 'fr|en|de']). Un_localelibre est un vecteur d'injection dans les chemins de templates/traductions. - Force https via
schemes: ['https'](redirige en 308) plutôt que de compter sur un reverse-proxy. Couplé àframework.router.default_uripour que la génération d'URL en CLI/queue produise des liens https. #[Route(env: 'dev')]pour tout endpoint de debug : en prod la route n'existe pas dans le matcher compilé → pas de surface d'attaque, pas juste un 403.- Méfie-toi des wildcards
{path}.+greedy : ils peuvent capturer/admin/...si mal priorisés.
Observabilité
_routeet_route_paramssont dansRequest::attributes. Toujours logger_route(le nom de la route), jamais le path brut, pour grouper les métriques :/articles/42et/articles/99sont la même routearticle_show. Cardinalité maîtrisée côté APM (Datadog, Tempo, Sentry).
// src/EventSubscriber/RouteNameLogSubscriber.php
final class RouteNameLogSubscriber implements EventSubscriberInterface
{
public function __construct(private readonly LoggerInterface $logger) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::CONTROLLER => ['onController', 0]];
}
public function onController(ControllerEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$route = $event->getRequest()->attributes->get('_route');
// Span/transaction name = nom de route → faible cardinalité
$this->logger->info('route.matched', ['route' => $route]);
}
}- Pour Sentry/Datadog, configure le transaction name sur
_routeet non sur l'URL : sans ça, chaqueidcrée une transaction distincte et explose la cardinalité. debug:router --show-controllersetrouter:match <path> --method=POSTsont tes outils de diagnostic prod (sur un dump de routes, pas besoin de DB).
🔁 Quand utiliser / éviter
- Attribute : code applicatif, controller dans
src/. Lisibilité maximale, refactoring IDE-friendly. - YAML : routes externes (non-controller via
#[AsController]), routes générées dynamiquement, ou ImportLoader (bundle externe). - Conditions : pour discrimination par header/host (API versioning, feature flags). Évite pour la logique métier — déplace dans le controller.
priority: utilise dès que tu as 2 routes qui pourraient matcher la même URL. Ne te repose pas sur l'ordre de déclaration.#[Route(env: 'dev')]: debug endpoints, fixtures loaders, profilers custom. Évite en prod : la route n'existe pas → pas de fuite.
🏋️ Exercices
Progression : implémenter → production-grade → casser puis réparer. Chaque exercice se valide avec router:match, debug:router et un test KernelTestCase.
1. Slug OU id sur la même URL (résolution de conflit)
Objectif : faire cohabiter /articles/42 (numérique) et /articles/mon-titre (slug) sans ambiguïté, en 404 propre sur /articles/!!!.
Indice/Solution : deux routes sur le même path-shape, départagées par requirements (id ⇒ \d+, slug ⇒ [a-z0-9-]+) et priority (id en premier). Vérifie avec router:match /articles/42 puis /articles/mon-titre que c'est bien la bonne route à chaque fois. Question piège : pourquoi priority est nécessaire ici alors que les requirements sont disjoints ? (Réponse : ils ne se chevauchent pas, donc priority n'est ici qu'une assurance de lisibilité — mais ajoute un /articles/{ref} .+ et observe ce qui casse.)
2. Routes localisées avec slugs traduits
Objectif : /fr/a-vendre/{ville}/{ref} et /en/for-sale/{ville}/{ref} pointant vers le même controller, avec path('bien_detail', {...}) qui génère la bonne URL selon _locale.
Indice/Solution : #[Route(path: ['fr' => '/a-vendre/{ville}/{ref}', 'en' => '/for-sale/{ville}/{ref}'], name: 'bien_detail')]. Symfony crée 2 routes bien_detail.fr / bien_detail.en ; la génération choisit selon _locale du context. Teste que generate('bien_detail', {ref:'PA123456', ville:'paris', _locale:'en'}) donne /en/for-sale/....
3. ValueResolver custom → entité depuis une ref publique
Objectif : transformer {ref} (ex. PA123456) en entité Bien injectée dans le controller, 404 si introuvable — sans utiliser MapEntity/EntityValueResolver standard.
Indice/Solution : implémente ValueResolverInterface::resolve() (cf. BienRefValueResolver plus haut), filtre sur $argument->getType() === Bien::class, throw new NotFoundHttpException si null. Autowiring le tague automatiquement. Test : un ref inexistant doit produire 404 avant d'entrer dans le controller.
4. (Production-grade) Forcer https + URLs absolues correctes depuis un worker de queue
Objectif : un Messenger handler envoie un email avec un lien path() ; en CLI le lien sort en http://localhost. Corrige pour https://app.example.com.
Indice/Solution : framework.router.default_uri: 'https://app.example.com' + schemes: ['https'] sur la route. Vérifie que UrlGeneratorInterface::ABSOLUTE_URL depuis un test CLI (bootKernel, pas de Request) produit l'URL https complète. Casse-le d'abord en retirant default_uri et observe le http://localhost.
5. (Casser puis réparer) Le wildcard greedy qui mange tout
Objectif : tu ajoutes une catch-all #[Route('/{path}', requirements: ['path' => '.+'])] pour un CMS, et soudain /admin et /api/health tombent dedans. Diagnostique et répare sans supprimer le catch-all.
Indice/Solution : reproduis avec router:match /admin --method=GET → il matche le catch-all. Répare via priority: -1000 sur la catch-all (matchée en dernier) et/ou un requirements excluant les préfixes réservés ((?!admin|api).+). Explique pourquoi l'ordre de déclaration ne suffit pas en mode attributes (c'est priority qui tranche, pas le fichier).
6. (Hard) Header-based API versioning sans dupliquer les endpoints stables
Objectif : /users/{id} doit router vers V2\UserController si header X-Api-Version: 2, sinon V1\UserController, en gardant DRY les endpoints identiques entre versions.
Indice/Solution : deux routes même path, départagées par condition: "request.headers.get('X-Api-Version') == '2'" (priority haute) et une route fallback v1. Les actions communes vivent dans un trait SharedUserActions inclus dans les deux controllers. Mesure le coût : la condition est évaluée à chaque requête (cf. modèle de coût plus haut) — garde-la sur request.headers.*, jamais d'I/O. Bonus : ajoute un header de réponse Deprecation (RFC 8594) sur v1 via un subscriber.
🎤 En entretien
Q : Le router itère-t-il sur toutes les routes à chaque requête ? Quel est le coût en O() ? Non. Le matcher est compilé en un fichier PHP statique : lookup hash O(1) pour les routes littérales, puis une seule preg_match sur une regex combinée avec groupes MARK. Le coût est ~indépendant du nombre de routes au runtime ; il se reporte sur le warmup et la mémoire.
Q : Une requête POST sur une route déclarée methods: ['GET'], c'est un 404 ou un 405 ? 405 Method Not Allowed (avec header Allow), pas 404 : le path a matché, seul le verbe a échoué. Le 404 (ResourceNotFoundException) ne survient que si aucun path ne matche.
Q : Quand utiliser une condition (Expression Language) plutôt qu'un requirements ou de la logique controller ?condition uniquement pour des critères non exprimables par une regex de path/host — typiquement des headers ou feature-flags. Jamais d'I/O : elle est évaluée en PHP à chaque requête, hors cache, sur le chemin chaud. Toute validation de forme va dans requirements (gratuit, encodé dans la regex) ; toute logique métier va dans le controller.
Q : Comment éviter l'explosion de cardinalité dans ton APM avec des URLs type /articles/42 ? Nommer les transactions/spans par _route (le nom de route, article_show) et non par le path brut. Request::attributes->get('_route') est disponible dès kernel.controller ; on l'utilise comme transaction name et comme clé de log pour regrouper toutes les valeurs de {id} sous une seule métrique.