/routing-define — Définir le routing Symfony
Utilise quand tu poses, renommes, contraintes ou localises une route, tu configures
default_uripour la génération hors requête, ou tu signes une URL à durée limitée. Pas quand tu écris le corps d'une action (lecture deRequest,#[MapRequestPayload], helpers d'AbstractController) →/symfony:controller-actioncouvre ce registre. Pas quand tu ajoutes juste un#[Route]trivial sur une nouvelle action —/symfony:controller-actioninclut le minimum syndical.
Tu déclares une ou plusieurs routes Symfony et tu t'assures qu'elles sont atteignables, typées, nommées, et référencées par nom partout où une URL est construite. Le routing Symfony ne se limite pas à un path — il porte aussi les méthodes HTTP acceptées, les contraintes sur les placeholders, l'environnement, le schéma, l'hôte, la condition d'activation et la génération d'URL à l'envers.
Détection préalable (obligatoire)
- Lire
composer.jsonà la racine du projet. - Vérifier
symfony/framework-bundledans les dépendances.- Présent → OK, continuer.
- Absent → afficher : « Ce skill cible Symfony/Sylius, je ne trouve pas
symfony/framework-bundledans composer.json. On continue quand même ou on change d'approche ? » et attendre la réponse.
- Repérer la convention de routing du projet :
config/routes/attributes.yaml(ou équivalent) +#[Route]sur les contrôleurs → standard Symfony 6+.config/routes.yamlavec des entrées YAML → legacy mais encore supporté ; ne pas mélanger les deux styles sans raison.- Annotations
@Route(Doctrine) → supprimées en Symfony 6.0, migrer vers les attributs PHP.
- Lire
config/packages/routing.yamlpour repérerdefault_uri(nécessaire pour générer des URLs depuis un worker, une commande, un handler Messenger). - Si
sylius/syliusest présent → les routes des Resources sont déjà posées parsylius/resource-bundle. Vérifierbin/console debug:router | grep sylius_avant de dupliquer une route CRUD.
Règles fondamentales
- Attributs PHP
#[Route]sur les contrôleurs : co-localisation route + action, analyse statique, aucune duplication YAML. Réserver YAML aux routes "configuration" (template direct, redirect controller, routes d'un bundle tiers montées à plusieurs endroits). - Toujours nommer la route (
name: 'product_show'). La génération d'URL passe par le nom, jamais par une chaîne littérale. Une URL en dur dans du code = dette. - Méthodes HTTP explicites (
methods: ['GET'],['POST'],['GET', 'POST']). Une route sansmethodsaccepte tout — surface d'attaque et faux positifs sur les mutations idempotentes. - Placeholders typés +
requirements:{id}seul matche n'importe quoi. Ajouterrequirements: ['id' => '\d+'](ou l'enumRequirement::DIGITS) pour borner. Pour une UUID :Requirement::UUID. Préférer les constantes deSymfony\Component\Routing\Requirement\Requirementaux regex magiques. - Préfixes au niveau classe :
#[Route('/product', name: 'product_')]sur la classe, puis#[Route('/{id}', name: 'show', methods: ['GET'])]sur les méthodes. Les noms se concatènent →product_show. Évite la duplication du préfixe URL et du préfixe de nom. - Pas de slash final dans les paths (
/product, pas/product/). Choisir la convention sans slash final et s'y tenir — Symfony traite/productet/product/comme deux routes différentes. - Stateless par défaut pour les API :
stateless: truedéclenche une exception si le handler touche à la session, ce qui protège le caching HTTP en amont. - Schéma explicite pour l'auth :
schemes: ['https']sur les routes de login, paiement, callbacks OAuth. Couplé àtrusted_proxies, garantit qu'une redirection HTTP → HTTPS est déclenchée côté framework. - Priority seulement en dernier recours : si deux routes se recouvrent (
/blog/{slug}vs/blog/list), la bonne réponse est de déclarer la plus spécifique d'abord ou d'ajouter unrequirementsqui les désambiguïse.priority: 2marche mais masque le chevauchement. #[Route]sur une classefinal: un contrôleur n'a pas à être étendu (cf./symfony:controller-action).- Un path, une responsabilité : une action par path + méthode. Pas de
switch ($request->getMethod())dans un handler — séparer en deux actions avecmethods: ['GET']etmethods: ['POST'].
Squelette canonique
// src/Controller/ProductController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement;
#[Route('/product', name: 'product_')]
final class ProductController extends AbstractController
{
#[Route('', name: 'index', methods: ['GET'])]
public function index(): Response { /* ... */ }
#[Route('/{id}', name: 'show', methods: ['GET'], requirements: ['id' => Requirement::DIGITS])]
public function show(int $id): Response { /* ... */ }
#[Route('/{slug}', name: 'show_by_slug', methods: ['GET'], requirements: ['slug' => '[a-z0-9-]+'])]
public function showBySlug(string $slug): Response { /* ... */ }
}
Routes résultantes : product_index (GET /product), product_show (GET /product/42), product_show_by_slug (GET /product/widget-xl). L'ordre de déclaration fixe la priorité — show (digits) passe avant show_by_slug (slug alphanum).
Placeholders et contraintes
Requirements via constantes
use Symfony\Component\Routing\Requirement\Requirement;
#[Route('/order/{uuid}', name: 'order_show', requirements: ['uuid' => Requirement::UUID])]
#[Route('/asset/{slug}', requirements: ['slug' => Requirement::ASCII_SLUG])]
#[Route('/page/{page}', requirements: ['page' => Requirement::POSITIVE_INT])]
#[Route('/file/{path}', requirements: ['path' => Requirement::CATCH_ALL])] // .+ (autorise les /)
Requirement::* est plus lisible qu'une regex et évite les erreurs classiques (\d+ refuse 0, pas [1-9]\d*).
Syntaxe inline
#[Route('/blog/{page<\d+>?1}', name: 'blog_list')]
public function list(int $page): Response { /* $page défaut 1 */ }
Format {nom<regex>?défaut}. Concis pour les cas simples, mais illisible au-delà. Préférer le tableau requirements + valeur par défaut dans l'argument dès qu'il y a 2 placeholders.
Forcer un slash dans un placeholder
#[Route('/share/{token}', requirements: ['token' => '.+'])]
public function share(string $token): Response { /* token peut contenir des / */ }
Attention : un placeholder permissif en fin de path peut ensuite casser la reconnaissance d'un {_format}. Préférer requirements: ['token' => '[^/]+'] sauf nécessité.
Backed enum en placeholder
use App\Enum\OrderStatus;
#[Route('/orders/{status}', name: 'orders_by_status', methods: ['GET'])]
public function byStatus(OrderStatus $status): Response { /* ... */ }
Symfony résout automatiquement la valeur de l'URL en instance d'enum (depuis 6.4). URL invalide → 404 sans code custom.
Méthodes HTTP, schéma, hôte
Méthodes multiples
#[Route('/profile/edit', name: 'profile_edit', methods: ['GET', 'POST'])]
public function edit(Request $request): Response { /* form handle, cf. /symfony:form-handle */ }
HTTPS obligatoire
#[Route('/login', name: 'login', methods: ['GET', 'POST'], schemes: ['https'])]
public function login(): Response { /* ... */ }
En dev HTTP pur, Symfony refusera de générer l'URL (ou la matchera en redirect vers HTTPS). Configurer trusted_proxies / trusted_headers pour que le reverse proxy serve correctement le schéma.
Sous-domaine
#[Route('/', name: 'mobile_home', host: 'm.{domain}', defaults: ['domain' => 'example.com'], requirements: ['domain' => 'example\.com|example\.fr'])]
public function mobileHome(): Response { /* ... */ }
host peut contenir des placeholders. Utile pour multi-tenant par sous-domaine — mais attention, le placeholder {domain} rentre dans la génération d'URL.
Condition (Expression Language)
#[Route(
'/api/beta/search',
name: 'api_beta_search',
condition: "context.getMethod() in ['GET', 'HEAD'] and request.headers.get('X-Beta') == 'true'"
)]
public function search(): Response { /* ... */ }
Dernier recours pour du matching complexe (feature flag, user-agent, header custom). Coûte une évaluation à chaque requête — préférer un Voter ou un middleware (kernel.controller listener) quand c'est du contrôle d'accès.
Environnement, alias, stateless
Route dev-only
#[Route('/_debug/dump-config', name: '_debug_dump_config', env: 'dev')]
public function dumpConfig(): Response { /* ... */ }
Invisible en prod — pas de risque d'oubli de firewall. Préférer au classique if ('dev' !== $this->kernel->getEnvironment()).
Alias (migration de nom)
#[Route('/product/{id}', name: 'product_details', alias: ['product_show'])]
public function show(int $id): Response { /* ... */ }
Permet de renommer une route sans casser les appels existants à redirectToRoute('product_show') / path('product_show'). Pour pousser à la migration :
use Symfony\Component\Routing\Attribute\DeprecatedAlias;
#[Route('/product/{id}', name: 'product_details', alias: [new DeprecatedAlias('product_show', 'app/core', '2.1')])]
Symfony émet une deprecation à chaque utilisation du vieux nom.
Stateless
#[Route('/api/ping', name: 'api_ping', methods: ['GET'], stateless: true)]
public function ping(): JsonResponse { return $this->json(['pong' => true]); }
Toute lecture de session dans le handler lève UnexpectedSessionUsageException. À poser systématiquement sur les routes d'API publiques.
Groupes et préfixes
Attributs au niveau classe
#[Route('/admin', name: 'admin_', requirements: ['_locale' => 'en|fr'])]
final class AdminController extends AbstractController
{
#[Route('/dashboard', name: 'dashboard', methods: ['GET'])]
public function dashboard(): Response { /* admin_dashboard — /admin/dashboard */ }
}
Les attributs requirements, defaults, schemes, methods, host, condition posés au niveau classe s'appliquent à toutes les méthodes.
Import YAML avec préfixe
# config/routes/admin.yaml
admin:
resource:
path: ../../src/Controller/Admin/
namespace: App\Controller\Admin
type: attribute
prefix: /admin
name_prefix: admin_
schemes: [https]
trailing_slash_on_root: false
À utiliser quand un même contrôleur doit être monté deux fois (ex : /admin/product et /vendor/{vendorId}/product). Sinon, préférer l'attribut au niveau classe.
Routes localisées (i18n)
#[Route(
path: [
'en' => '/about-us',
'fr' => '/a-propos',
'nl' => '/over-ons',
],
name: 'about',
methods: ['GET'],
)]
public function about(): Response { /* ... */ }
Trois routes générées : about.en, about.fr, about.nl. L'injection de $_locale (ou l'usage de $request->getLocale()) fonctionne sans placeholder explicite. Génération :
{{ path('about', {_locale: 'fr'}) }} {# → /a-propos #}
Pour un préfixe de locale systématique (/en/..., /fr/...), préférer un import YAML avec prefix: { en: '', fr: '/fr' }.
Génération d'URL
Depuis un contrôleur (AbstractController)
$this->generateUrl('product_show', ['id' => 42]); // /product/42
$this->generateUrl('product_show', ['id' => 42], UrlGeneratorInterface::ABSOLUTE_URL); // https://example.com/product/42
$this->redirectToRoute('product_show', ['id' => 42], 302);
Depuis un service (injection)
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class InvoiceMailer
{
public function __construct(private readonly UrlGeneratorInterface $urls) {}
public function send(Invoice $invoice): void
{
$link = $this->urls->generate(
'invoice_show',
['id' => $invoice->getId()],
UrlGeneratorInterface::ABSOLUTE_URL,
);
// ... envoi mail
}
}
Depuis Twig
<a href="{{ path('product_show', {id: product.id}) }}">{{ product.name }}</a>
<link rel="canonical" href="{{ url('product_show', {id: product.id}) }}">
path() = relatif, url() = absolu. Un paramètre qui n'existe pas dans la définition de la route est ajouté en query string.
Depuis un worker / une commande / un handler Messenger
Aucune Request en contexte → UrlGeneratorInterface::ABSOLUTE_URL tombe sur http://localhost par défaut. Configurer default_uri :
# config/packages/routing.yaml
framework:
router:
default_uri: 'https://example.com'
Sans cette config, les URLs dans les mails envoyés depuis un worker pointent vers localhost — classique régression en prod.
Modes de génération
| Constante | Résultat |
|---|---|
ABSOLUTE_PATH (défaut) |
/product/42 |
ABSOLUTE_URL |
https://example.com/product/42 |
NETWORK_PATH |
//example.com/product/42 |
RELATIVE_PATH |
../product/42 |
URIs signées
Pour un lien à usage limité (validation email, téléchargement temporaire, webhook callback) :
use Symfony\Component\HttpFoundation\UriSigner;
final class ConfirmationLinkBuilder
{
public function __construct(
private readonly UrlGeneratorInterface $urls,
private readonly UriSigner $signer,
) {}
public function build(User $user): string
{
$url = $this->urls->generate('user_confirm', ['id' => $user->getId()], UrlGeneratorInterface::ABSOLUTE_URL);
return $this->signer->sign($url, new \DateInterval('PT1H')); // valide 1h
}
}
Côté contrôleur de réception :
use Symfony\Component\HttpKernel\Attribute\IsSignatureValid;
#[Route('/user/{id}/confirm', name: 'user_confirm', methods: ['GET'])]
#[IsSignatureValid]
public function confirm(int $id): Response { /* ... */ }
L'attribut #[IsSignatureValid] lève 403 si la signature est invalide ou expirée. Ne pas rouler sa propre vérification hash_equals — le composant gère les constants-time comparisons et l'expiration.
Debugging
symfony console debug:router # toutes les routes (nom, méthode, schéma, path)
symfony console debug:router product_show # détail d'une route (regex compilée, defaults, requirements)
symfony console debug:router --show-aliases # inclut les alias
symfony console debug:router --show-controllers # inclut le contrôleur cible
symfony console debug:router --method=POST # filtre par méthode
symfony console router:match /product/42 # quelle route matche cette URL, et pourquoi les autres ne matchent pas
symfony console router:match /product/42 --method=POST # match avec méthode
symfony console cache:clear # après ajout/suppression de route
router:match est l'outil décisif quand une URL renvoie 404 inexplicable — il liste chaque route candidate et affiche la raison du rejet (path, method, host, condition).
Pièges fréquents
- Slash final incohérent :
/productvs/product/— 404 silencieux. Choisir sans slash final, être cohérent. methods:oublié : l'action accepteGET|POST|PUT|DELETE|...par défaut. Formulaires exposés en GET, mutations idempotentes, surface CSRF élargie.- URL en dur :
return $this->redirect('/product/'.$id)→ casse dès qu'on déplace le préfixe. ToujoursredirectToRoute('product_show', ['id' => $id]). - Paramètre manquant à la génération :
MissingMandatoryParametersException— la route a un placeholder non fourni.symfony console debug:router <nom>affiche les placeholders requis. - Ordre des routes :
/{slug}déclarée avant/listcatch tout. Déclarer le spécifique avant le générique, ou ajouter unrequirementsqui exclut l'autre. hostsanstrusted_hosts: en prod, Symfony refuse de matcher siframework.trusted_hostsn'autorise pas l'hôte. Symptôme : 404 sur toutes les routes après passage en prod derrière un domaine custom.- Annotations
@Routedans un projet 6+ :doctrine/annotationsa été retiré. Migrer vers les attributs PHP (php bin/console make:controllergénère déjà des#[Route]). - URL absolue dans un worker sans
default_uri: les mails transactionnels linkent vershttp://localhost. Configurerframework.router.default_uri. - Condition avec un service non existant :
service('foo')dans uneconditioncrash au boot. L'expression est compilée au premier match —lint:containerne le détecte pas toujours. - Routes localisées sans
_localeendefaultsau niveau groupe : le matching marche mais la génération d'URL demande_localeexplicitement à chaquepath(). stateless: true+getUser(): dans certains firewalls,getUser()hydrate la session. Vérifier le firewall stateless (stateless: truesur la firewall config aussi) si l'exception tombe.
Vérifications
symfony console debug:router
symfony console router:match <url>
symfony console lint:yaml config/routes/
symfony console cache:clear
vendor/bin/phpstan analyse src/Controller
Pour un smoke test rapide d'un ensemble de routes :
for route in product_index product_show product_create; do
symfony console debug:router "$route" > /dev/null 2>&1 && echo "OK $route" || echo "KO $route"
done
Delta Sylius
- Les Resources Sylius (
sylius.product,sylius.order, …) génèrent automatiquement les routes CRUD viasylius/resource-bundle. Nommage :sylius_admin_product_index,sylius_shop_product_show. Vérifier avant d'en créer une :bin/console debug:router | grep sylius_<resource>. - Pour surcharger une route Sylius (changer le path, ajouter une contrainte), passer par
config/routes/sylius_*.yamlou_sylius.yamlcôté resource config — pas par un nouveau#[Route]qui entrerait en conflit. - Les routes custom (dashboard admin, webhook paiement, export) vivent sous
App\Controller\comme dans tout projet Symfony, et suivent la convention de nommage du projet (souventapp_admin_<action>ouapp_shop_<action>). - Attention au
trailing_slash_on_rootde l'import YAML Sylius : certains templates admin génèrent/admin/, d'autres/admin— les deux doivent résoudre. LeRedirectableUrlMatcherde Symfony s'en occupe si la config le laisse faire.
Déroulement
1 — Cadrer
- Path + méthode HTTP + nom de route.
- Placeholders : type, contrainte (
Requirement::*), valeur par défaut éventuelle. - Portée : env dev-only ? schema HTTPS ? stateless ? localisée ? sous-domaine ?
- Où cette route est-elle générée (Twig, service, mail, API externe) ? Si externe →
default_uriconfiguré.
2 — Implémenter
#[Route]sur le contrôleur (préfixe classe + attribut méthode) OU entrée YAML pour un cas "configuration".- Toujours :
name,methods,requirementssur les placeholders non triviaux. - Si besoin de génération asynchrone (worker, commande) → vérifier
framework.router.default_uri.
3 — Vérifier
bin/console debug:router <name>→ route présente, path + method + requirements corrects.bin/console router:match <url>→ URL testée matche bien cette route.bin/console cache:clearsi la route n'apparaît pas.curl -I <url>(ou navigateur) → 200/302/404 attendu.
4 — Clôture
Afficher :
- Fichier touché, nom(s) de route, path, méthode(s).
- Contraintes posées (
requirements,schemes,stateless,env,host). - Ce qui reste : action du contrôleur (
/symfony:controller-action), form (/symfony:form-handle), sérialisation API (/symfony:serializer-use), sécurité (voter, firewall), tests fonctionnels.
Argument optionnel
/symfony:routing-define ProductController show /product/{id} GET — scaffolde la route product_show dans ProductController (classe créée si absente, Requirement::DIGITS appliqué sur {id}).
/symfony:routing-define src/Controller/OrderController.php — audit des routes du fichier (noms, méthodes bornées, requirements, duplications, ordre de priorité, usage de redirectToRoute ailleurs dans le code).
/symfony:routing-define sans argument — demande path, méthode, nom de route et comportement attendu.