Chapitre 4.2 — Routing par attributs
Partie 4 : Cœur HTTP — Chapitre 2/6 Version de référence : Symfony 7.4.
⏱️ TL;DR
- Une route associe URL + méthode(s) HTTP à une méthode de contrôleur, via l’attribut
#[Route].- Un
#[Route]sur la classe définit un préfixe (chemin et préfixe de nom) commun à toutes ses méthodes.- Paramètres
{id}, contraintes ({id<\d+>}ourequirements), valeurs par défaut, méthodes (methods: ['GET','POST']), priorité, format, hôte, schéma.- Chaque route a un nom → on génère les URLs par leur nom (
generateUrl(),path()en Twig), jamais en dur.- Diagnostic :
debug:router(liste),router:match /url(quelle route matche).- Pour un dev Next.js : au lieu d’un routing par fichiers (
app/bookings/[id]/route.ts), c’est un routing par annotations sur méthodes — plus explicite, centralisable, et nommé (les URLs se génèrent à partir du nom).
🎯 Objectifs
- Déclarer des routes riches (paramètres, contraintes, méthodes, priorité).
- Factoriser avec un préfixe au niveau de la classe.
- Générer des URLs par nom, côté PHP et Twig.
- Diagnostiquer le routing en ligne de commande.
1. La route de base
// src/Controller/BookingController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
final class BookingController extends AbstractController
{
#[Route('/bookings/{id}', name: 'booking_show', methods: ['GET'])]
public function show(int $id): JsonResponse
{
return $this->json(['id' => $id]);
}
}'/bookings/{id}': le chemin, avec un paramètre{id}.name: 'booking_show': le nom de la route (identifiant unique, sert à générer l’URL).methods: ['GET']: les méthodes HTTP acceptées.
Le paramètre {id} est injecté dans l’argument int $id du même nom (résolution d’arguments, chapitre 4.4). Le type int déclenche une conversion.
2. Préfixe au niveau de la classe
Un #[Route] posé sur la classe s’applique en préfixe à toutes les méthodes — idéal pour regrouper une API :
#[Route('/api/bookings', name: 'api_booking_')]
final class BookingApiController extends AbstractController
{
#[Route('', name: 'list', methods: ['GET'])]
public function list(): JsonResponse { /* GET /api/bookings, nom api_booking_list */ }
#[Route('/{id}', name: 'show', methods: ['GET'])]
public function show(int $id): JsonResponse { /* GET /api/bookings/42, nom api_booking_show */ }
#[Route('', name: 'create', methods: ['POST'])]
public function create(): JsonResponse { /* POST /api/bookings, nom api_booking_create */ }
}Le préfixe de chemin (/api/bookings) et de nom (api_booking_) se concatènent avec ceux des méthodes. On obtient des noms cohérents (api_booking_list, api_booking_show…) sans répétition.
3. Contraintes de paramètres
On restreint un paramètre par une expression régulière, en ligne (syntaxe {param<regex>}) ou via requirements :
// en ligne (concis) : id doit être un entier
#[Route('/bookings/{id<\d+>}', name: 'booking_show', methods: ['GET'])]
// équivalent explicite
#[Route('/bookings/{id}', name: 'booking_show', requirements: ['id' => '\d+'], methods: ['GET'])]
// plusieurs segments, slug
#[Route('/providers/{slug<[a-z0-9-]+>}', name: 'provider_show', methods: ['GET'])]Sans contrainte, {id} capture n’importe quoi (sauf /). La contrainte \d+ évite qu’une URL comme /bookings/abc ne matche cette route (elle renverra alors 404 ou tombera sur une autre route).
4. Valeurs par défaut et paramètres optionnels
Un paramètre avec valeur par défaut (côté méthode) devient optionnel :
#[Route('/bookings/{page<\d+>}', name: 'booking_list', methods: ['GET'])]
public function list(int $page = 1): JsonResponse
{
// /bookings → page 1 ; /bookings/3 → page 3
}5. Autres options utiles
#[Route(
path: '/bookings/{id}',
name: 'booking_show',
methods: ['GET'],
requirements: ['id' => '\d+'],
defaults: ['_format' => 'json'],
schemes: ['https'], // force HTTPS
condition: "request.headers.get('Accept') matches '/json/'", // condition d'expression
)]schemes: restreint au HTTP/HTTPS.condition: une expression (langage ExpressionLanguage) évaluée à l’exécution — utile pour router selon un en-tête, par ex.priority: en cas de chevauchement, la route de priorité la plus haute gagne (voir §7).host: router selon le sous-domaine (admin.booklab.test).
⚡ Depuis Symfony 5 : rien de conceptuellement neuf, mais tout est passé en attributs (là où vous aviez
@Route(...)en annotations ou du YAML dansconfig/routes.yaml). La syntaxe en ligne des contraintes{id<\d+>}est devenue idiomatique. Le YAML de routing existe toujours mais on l’utilise rarement pour ses propres contrôleurs.
6. Générer des URLs (jamais en dur !)
On ne code jamais une URL en dur (/bookings/42). On la génère à partir du nom de route — ainsi, changer un chemin ne casse rien.
Côté contrôleur / service :
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
// dans un AbstractController :
$url = $this->generateUrl('booking_show', ['id' => 42]);
// → /bookings/42
// URL absolue (pour un e-mail, un webhook) :
$abs = $this->generateUrl('booking_show', ['id' => 42], UrlGeneratorInterface::ABSOLUTE_URL);
// → https://booklab.test/bookings/42Côté Twig (Partie 7) :
<a href="{{ path('booking_show', { id: booking.id }) }}">Voir</a>
{# url(...) pour l'absolu #}⚠️ Piège : coder les URLs en dur est la source n°1 de liens cassés lors d’un refactor. Toujours passer par le nom de route. Le nom est un contrat stable ; le chemin, un détail d’implémentation qui peut changer.
💡 Pour un dev Next.js : c’est l’équivalent de ne pas hardcoder les chemins mais d’utiliser des helpers de liens typés. Ici, le « nom de route » joue le rôle d’identifiant stable, et
generateUrl/pathfabrique l’URL réelle — pratique surtout quand l’API génère des liens (HATEOAS, e-mails, redirections OAuth).
7. Priorité et ordre de résolution
Le routeur teste les routes dans l’ordre et retient la première qui matche. En cas de chevauchement (deux routes pourraient matcher la même URL), on force l’ordre avec priority (plus haute = testée en premier) :
#[Route('/bookings/new', name: 'booking_new', priority: 10, methods: ['GET'])]
public function new(): Response { /* ... */ }
#[Route('/bookings/{id}', name: 'booking_show', methods: ['GET'])]
public function show(int $id): Response { /* ... */ }Sans priorité, /bookings/new risquerait de matcher /bookings/{id} (avec id = "new"). La contrainte {id<\d+>} règle aussi ce cas (car new n’est pas \d+). Deux façons d’éviter les collisions : contraintes précises ou priorité.
8. Diagnostiquer le routing
php bin/console debug:router
# liste toutes les routes : nom, méthode, schéma, chemin
php bin/console debug:router booking_show
# détail d'une route précise
php bin/console router:match /bookings/42 --method=GET
# quelle route matche cette URL ? (très utile pour déboguer un 404)router:match est précieux : il dit exactement quelle route (le cas échéant) capte une URL donnée, et pourquoi une autre ne matche pas.
✏️ Exercices
Exercice 1. Écrivez un contrôleur ProviderApiController préfixé sur /api/providers (préfixe de nom api_provider_) avec : GET /api/providers (list), GET /api/providers/{id} (id entier, show), POST /api/providers (create).
✅ Solution
#[Route('/api/providers', name: 'api_provider_')]
final class ProviderApiController extends AbstractController
{
#[Route('', name: 'list', methods: ['GET'])]
public function list(): JsonResponse { return $this->json([]); }
#[Route('/{id<\d+>}', name: 'show', methods: ['GET'])]
public function show(int $id): JsonResponse { return $this->json(['id' => $id]); }
#[Route('', name: 'create', methods: ['POST'])]
public function create(): JsonResponse { return $this->json([], 201); }
}Noms générés : api_provider_list, api_provider_show, api_provider_create.
Exercice 2. Pourquoi /bookings/new peut-il être capté par la route /bookings/{id}, et deux façons de l’éviter ?
✅ Solution
{id} capture n’importe quel segment, donc new matcherait {id} avec id = "new". Deux solutions : (1) contraindre {id} à un entier ({id<\d+>}), ce qui exclut new ; (2) donner une priorité plus haute à la route /bookings/new pour qu’elle soit testée d’abord.
Exercice 3. Un lien vers booking_show est actuellement écrit href="/bookings/{{ booking.id }}". Pourquoi est-ce fragile, et comment le corriger ?
✅ Solution
C’est une URL codée en dur : si le chemin de la route change (/bookings/{id} → /reservations/{id}), tous ces liens cassent silencieusement. Correction : générer l’URL par le nom de route — href="{{ path('booking_show', { id: booking.id }) }}". Le nom est stable, le chemin peut évoluer sans casse.
🧠 Quiz de révision
1. Que définit un #[Route] posé sur la classe du contrôleur ?
#[Route] posé sur la classe du contrôleur ?Un préfixe de chemin et de nom appliqué à toutes les méthodes de la classe (concaténé avec leurs propres #[Route]).
2. Comment contraindre {id} à un entier ?
{id} à un entier ?En ligne {id<\d+>} ou via requirements: ['id' => '\d+'].
3. Comment génère-t-on une URL à partir d’un nom de route, en PHP et en Twig ?
En PHP : $this->generateUrl('nom', [params]) (ou UrlGeneratorInterface). En Twig : path('nom', {params}) (relatif) ou url('nom', {params}) (absolu).
4. Quelle commande dit quelle route matche une URL donnée ?
php bin/console router:match /chemin --method=GET.
5. Pourquoi ne jamais coder une URL en dur ?
Parce qu’un changement de chemin casserait tous les liens en dur. Générer par nom rend le chemin modifiable sans casse : le nom est le contrat stable.
Prochain chapitre : 4.3 — Contrôleurs & réponses — AbstractController, types de réponses, codes HTTP, erreurs, et l’art des contrôleurs « fins ».