Skip to content

Routing — attributes, conditions, i18n, expression language

TL;DR — Le router Symfony compile un tableau de routes en un PHP Matcher ultra-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:match sont 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 attributes

Analogie : 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 :

  1. Une regex unique combinée (combine de toutes les routes "statiques-compatibles") avec des groupes nommés (?P<MARK>...). Une seule passe preg_match te dit quelle route a matché via le marqueur capturé. C'est O(taille de la regex), pas O(nombre de routes).
  2. Un tableau statique pour les routes purement littérales (/health, /login) — lookup hash O(1), zéro regex.
  3. Une liste "dynamique" pour les routes avec condition, host variable, ou methods multiples 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ée

Consé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écanismeCoût runtimeEncodé dans la regex compilée ?Quand l'utiliser
requirements (regex sur param)quasi nul✅ ouiToujours pour valider la forme d'un param (\d+, slug, locale)
methodstrès faible (post-check)partielDiscrimination REST GET/POST/PUT/DELETE
schemestrès faiblepartielForcer https (redirection auto 308)
host (statique)faible✅ ouiMulti-tenant / multi-marque par domaine
host (variable {tld})faiblepartielDomaines paramétrés
condition (ExpressionLanguage)moyen, à chaque req❌ nonHeader/feature-flag uniquement, jamais d'I/O
Logique dans le controllerdépendn/aDè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é

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

yaml
# 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 :

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

twig
{{ path('article_show', { id: 42 }) }}
{{ url('article_show', { id: 42 }) }}

🎯 Patterns courants

  1. 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.
  2. Priority pour résoudre les conflits/{slug} vs /{id} qui matchent tous deux /foo : priority décide. Plus haut = matched en premier.
  3. Localized routes — passer path: ['fr' => '/articles', 'en' => '/posts'] génère 2 routes distinctes, sélection automatique selon _locale.
  4. Conditions expression languagecondition: "request.headers.get('X-Api-Version') == 'v2'" pour A/B testing, versioning header-based, feature flags.
  5. Route requirements regexrequirements: ['id' => '\d+', 'lang' => 'fr|en|de'] empêche match sur des valeurs invalides → 404 propre, jamais d'exception runtime.
  6. #[Route(env: 'dev')] (6.2+) — route activée uniquement en env dev (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 @Route ET attributes #[Route] co-existent. Namespace Symfony\Component\Routing\Annotation\Route (legacy) ET Symfony\Component\Routing\Attribute\Route (nouveau).
  • 6.0 : annotations supprimées dans les makers/recipes. Le namespace Annotation\Route reste mais aliasé vers Attribute\Route. host, schemes, methods, condition tous configurables via attribute.
  • 6.2 : #[Route(env: 'dev')] pour env-conditional routes. Path localisé via array path: ['fr' => ..., 'en' => ...].
  • 6.3 : #[Route(format: 'json')] shortcut pour defaults: { _format: json }. #[Route(stateless: true)] shortcut.
  • 6.4 LTS : namespace Symfony\Component\Routing\Annotation\Route deprecated → utiliser Attribute\Route. Génération de routes avec UrlGeneratorInterface::ABSOLUTE_URL thread-safe en worker mode.
  • 7.0 : suppression définitive du namespace Annotation\Route. Tu dois utiliser Attribute\Route. Fail au boot sinon.
  • 7.1+ : amélioration du caching des matchers pour gros volumes de routes (>1000). UrlMatcher retourne des objets typés au lieu d'arrays purs (préparation typed routes).

⚠️ Pitfalls

  1. Trailing slash mismatch/articles et /articles/ sont 2 routes différentes. Configure soit l'une soit l'autre, ou utilise framework.router.utf8: true + redirect via listener.
  2. Order matters en YAML — la première route matchée gagne ; en attributes c'est priority qui décide. Mixer les deux = ordre indéterminé, utilise priority partout.
  3. requirements ignoré — si tu mets requirements: ['id' => 'd+'] (oublie le \), ça matche d, dd, etc., pas des chiffres. Tester avec router:match.
  4. Expression condition non-cacheable — l'expression est évaluée à chaque requête. Évite des conditions lourdes (DB call) ; reste sur request.*, context.*.
  5. _locale non-restreint — sans requirements: ['_locale' => 'fr|en'], un attaquant peut passer _locale=../etc/passwd et casser des choses. Toujours whitelister.
  6. URL generation context manquant — en CLI (bin/console), RequestContext n'a pas de host par défaut → URLs absolues cassées. Configurer framework.router.default_uri: 'https://example.com'.
  7. Routes deprecated mais utilisées — pas de mécanisme natif pour deprecate une route. Stratégie : listener qui ajoute un header Deprecation: + log.
  8. Wildcard {path} greedy/{path} match TOUT, y compris des routes plus spécifiques déclarées après. Mettre priority: -100 ou utiliser requirements: ['path' => '.+'] consciemment.

🧪 Testing

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

php
public function testGetArticle(): void
{
    $client = static::createClient();
    $client->request('GET', '/fr/articles/42');
    self::assertResponseIsSuccessful();
    self::assertRouteSame('article_show', ['id' => '42']);
}

Debug CLI essentiel :

bash
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 :

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

php
// 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];
    }
}
php
// 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),
        ]);
    }
}
php
// 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:warmup avant 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.php et 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} avec requirements enum plutôt que N routes quasi identiques.
  • En runtime longue durée (FrankenPHP, RoadRunner, Swoole), le RequestContext est 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 _locale systématiquement (requirements: ['_locale' => 'fr|en|de']). Un _locale libre 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_uri pour 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é

  • _route et _route_params sont dans Request::attributes. Toujours logger _route (le nom de la route), jamais le path brut, pour grouper les métriques : /articles/42 et /articles/99 sont la même route article_show. Cardinalité maîtrisée côté APM (Datadog, Tempo, Sentry).
php
// 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 _route et non sur l'URL : sans ça, chaque id crée une transaction distincte et explose la cardinalité.
  • debug:router --show-controllers et router:match <path> --method=POST sont 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.

🔗 Liens

Bibliothèque tech perso — Achref