Chapitre 4.3 — Contrôleurs & réponses
Partie 4 : Cœur HTTP — Chapitre 3/6 Version de référence : Symfony 7.4.
⏱️ TL;DR
- Un contrôleur est une méthode qui renvoie une
Response. En héritant d’AbstractController, on gagne des raccourcis :render(),json(),redirectToRoute(),createNotFoundException(),denyAccessUnlessGranted(),getUser(),addFlash().- Types de réponses :
Response(générique),JsonResponse,RedirectResponse,BinaryFileResponse(téléchargement),StreamedResponse(flux).- Codes HTTP via les constantes
Response::HTTP_*(jamais de nombres magiques).- Erreurs :
throw $this->createNotFoundException()→ 404,throw new AccessDeniedException()→ 403. Le pipeline (kernel.exception) les transforme en réponses propres.- Règle d’or : contrôleurs fins. Ils orchestrent (lire la requête, appeler un service, renvoyer une réponse) ; la logique métier vit dans des services (Partie 5), pas dans le contrôleur.
- Un contrôleur peut recevoir ses dépendances par injection dans la signature (autowiring d’arguments).
🎯 Objectifs
- Utiliser les raccourcis d’
AbstractControllerà bon escient. - Choisir le bon type de
Responseet le bon code HTTP. - Lever des erreurs HTTP proprement.
- Écrire des contrôleurs fins qui délèguent aux services.
1. Un contrôleur, c’est quoi exactement
Techniquement, un contrôleur est n’importe quel callable qui renvoie une Response. En pratique, c’est une méthode publique d’une classe dans src/Controller/, avec un #[Route]. Hériter d’AbstractController est optionnel mais quasi systématique : ça apporte des helpers pratiques.
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class BookingController extends AbstractController
{
#[Route('/bookings/{id<\d+>}', name: 'booking_show', methods: ['GET'])]
public function show(int $id): Response
{
// ... on récupère la réservation (via un service/repository)
return $this->json(['id' => $id, 'status' => 'confirmed']);
}
}2. Les raccourcis d’AbstractController
| Méthode | Rôle |
|---|---|
$this->render('tpl.html.twig', [...]) | Rendre un template Twig → Response HTML |
$this->json($data, $status) | Sérialiser en JSON → JsonResponse |
$this->redirectToRoute('nom', [params]) | Redirection vers une route → RedirectResponse |
$this->redirect($url) | Redirection vers une URL absolue |
$this->createNotFoundException($msg) | Crée une NotFoundHttpException (→ 404) à throw |
$this->createAccessDeniedException($msg) | Crée une AccessDeniedException (→ 403) à throw |
$this->denyAccessUnlessGranted($attr, $subj) | Vérifie un droit, lève 403 sinon (Partie 10) |
$this->getUser() | L’utilisateur authentifié (ou null) |
$this->addFlash('success', 'msg') | Message flash (chapitre 4.5) |
$this->createForm(Type::class, $data) | Crée un formulaire (Partie 9) |
$this->file($path) | Réponse de téléchargement de fichier |
$this->generateUrl('nom', [params]) | Génère une URL par nom |
3. Choisir le type de réponse et le code HTTP
use Symfony\Component\HttpFoundation\Response;
// 200 OK — HTML
return $this->render('booking/show.html.twig', ['booking' => $booking]);
// 201 Created — JSON, avec un en-tête Location
return $this->json($booking, Response::HTTP_CREATED, [
'Location' => $this->generateUrl('api_booking_show', ['id' => $booking->getId()]),
]);
// 204 No Content — suppression réussie, pas de corps
return new Response(null, Response::HTTP_NO_CONTENT);
// 302 — redirection après un POST (pattern POST/Redirect/GET)
return $this->redirectToRoute('booking_list');
// 422 — données invalides (API)
return $this->json(['errors' => $errors], Response::HTTP_UNPROCESSABLE_ENTITY);Utilisez toujours les constantes Response::HTTP_* : HTTP_OK (200), HTTP_CREATED (201), HTTP_NO_CONTENT (204), HTTP_FOUND (302), HTTP_BAD_REQUEST (400), HTTP_UNAUTHORIZED (401), HTTP_FORBIDDEN (403), HTTP_NOT_FOUND (404), HTTP_UNPROCESSABLE_ENTITY (422). Le code raconte l’intention.
⚠️ Piège API : renvoyer 200 pour tout, même les erreurs, avec un
{"error": ...}dans le corps. C’est un anti-pattern : respectez la sémantique HTTP (404 pour introuvable, 422 pour validation, 401/403 pour l’auth). Vos consommateurs (dont le front Next.js) s’appuient sur le code pour brancher leur logique (if (res.status === 404)), pas sur le corps.
4. Gérer les erreurs
On ne construit pas des réponses d’erreur à la main partout : on lève une exception, et le pipeline (kernel.exception, chapitre 4.1) la transforme en réponse adéquate.
$booking = $repository->find($id);
if ($booking === null) {
throw $this->createNotFoundException(sprintf('Réservation #%d introuvable.', $id));
// → réponse 404 (page HTML en dev/prod, JSON si API)
}
// accès refusé (détaillé en Partie 10)
if (!$this->isGranted('EDIT', $booking)) {
throw $this->createAccessDeniedException(); // → 403
}Vous pouvez aussi lancer directement les exceptions HTTP du composant : NotFoundHttpException, BadRequestHttpException, ConflictHttpException, etc. (namespace Symfony\Component\HttpKernel\Exception). Chacune est mappée à son code HTTP.
💡 Pour un dev Next.js : au lieu de
return NextResponse.json({error}, {status: 404}), onthrowune exception sémantique et Symfony fabrique la réponse. C’est plus découplé : le contrôleur exprime « introuvable » sans se soucier du format (HTML vs JSON) — c’est le gestionnaire d’exception qui décide selon le contexte.
5. Injecter des dépendances dans un contrôleur
Un contrôleur est un service : Symfony peut injecter ce dont il a besoin. Deux niveaux :
- Dans la signature de la méthode (le plus courant) — Symfony autowire les services et résout les arguments :
use Psr\Log\LoggerInterface;
use App\Service\BookingCreator;
#[Route('/bookings', name: 'booking_create', methods: ['POST'])]
public function create(
BookingCreator $creator, // service métier, injecté automatiquement
LoggerInterface $logger, // logger, injecté automatiquement
): JsonResponse {
$booking = $creator->createFromRequest(/* ... */);
$logger->info('Réservation créée', ['id' => $booking->getId()]);
return $this->json($booking, 201);
}- Dans le constructeur — pour des dépendances utilisées par plusieurs méthodes de la classe.
On approfondit l’injection de dépendances en Partie 5 ; retenez ici qu’un contrôleur reçoit ses collaborateurs, il ne les crée pas avec new.
6. La règle d’or : des contrôleurs fins
Un bon contrôleur fait trois choses : (1) lire l’entrée (déjà transformée en objets typés), (2) déléguer à un service métier, (3) renvoyer une réponse. Il ne contient pas la logique métier ni les requêtes SQL complexes.
// ❌ Contrôleur « gras » : logique métier dans le contrôleur
public function confirm(int $id, EntityManagerInterface $em): JsonResponse
{
$booking = $em->getRepository(Booking::class)->find($id);
if (!$booking) { throw $this->createNotFoundException(); }
if ($booking->getStatus() === BookingStatus::Cancelled) { /* règle... */ }
$booking->setStatus(BookingStatus::Confirmed);
// ... 30 lignes de règles, e-mails, calculs ...
$em->flush();
return $this->json($booking);
}
// ✅ Contrôleur « fin » : délègue au service
public function confirm(Booking $booking, BookingConfirmer $confirmer): JsonResponse
{
$confirmer->confirm($booking); // toute la logique vit dans le service
return $this->json($booking);
}Le second est testable (on teste BookingConfirmer en isolation), réutilisable (la confirmation peut venir d’une commande console, d’un message async…) et lisible. C’est la ligne directrice de tout le cours.
⚠️ Piège : injecter
EntityManagerInterfacedirectement dans le contrôleur et y écrire la logique est tentant (ça « marche »), mais ça produit des contrôleurs impossibles à tester et à réutiliser. Dès qu’il y a une règle métier, créez un service (Partie 5) et faites-le injecter.
✏️ Exercices
Exercice 1. Écrivez une action DELETE /api/bookings/{id} qui, en cas de succès, renvoie une réponse 204 sans corps. (Supposez un service BookingCanceller avec une méthode cancel(int $id): void.)
✅ Solution
#[Route('/api/bookings/{id<\d+>}', name: 'api_booking_delete', methods: ['DELETE'])]
public function delete(int $id, BookingCanceller $canceller): Response
{
$canceller->cancel($id);
return new Response(null, Response::HTTP_NO_CONTENT); // 204
}Le contrôleur reste fin : il délègue à BookingCanceller et renvoie le bon code. (Si l’entité est introuvable, cancel() lèvera une exception mappée en 404.)
Exercice 2. Un collègue renvoie systématiquement 200 avec {"success": false, "error": "not found"}. Pourquoi est-ce problématique pour le front Next.js, et que proposez-vous ?
✅ Solution
Le front ne peut plus se fier au code HTTP pour brancher sa logique (res.ok, res.status === 404) : tout est 200, il doit inspecter le corps à chaque fois, et les outils/CDN/monitoring voient des « succès » alors qu’il y a erreur. Proposition : respecter la sémantique HTTP — 404 pour introuvable, 422 pour validation, 401/403 pour l’auth — en levant les exceptions appropriées. Le corps peut détailler l’erreur, mais le code doit être juste.
Exercice 3. Transformez ce contrôleur « gras » en contrôleur « fin » (décrivez le service à extraire) :
public function register(Request $request, EntityManagerInterface $em, MailerInterface $mailer): Response
{
// 25 lignes : validation, création User, hash mot de passe, envoi email, flush...
}✅ Solution
Extraire un service UserRegistrar (dans src/Service/) avec une méthode register(RegisterInput $input): User qui contient les 25 lignes (validation déléguée au Validator, création de l’entité, hash du mot de passe, persistance, déclenchement de l’e-mail — idéalement via un message async). Le contrôleur devient :
public function register(#[MapRequestPayload] RegisterInput $input, UserRegistrar $registrar): Response
{
$user = $registrar->register($input);
return $this->json($user, Response::HTTP_CREATED);
}Contrôleur fin, service testable et réutilisable (console, API, worker).
🧠 Quiz de révision
1. Que doit renvoyer un contrôleur ?
Une Response (ou une sous-classe : JsonResponse, RedirectResponse…). S’il renvoie autre chose, un listener sur kernel.view doit la convertir.
2. Comment lève-t-on une 404 depuis un contrôleur ?
throw $this->createNotFoundException('...') (ou throw new NotFoundHttpException('...')). Le pipeline la transforme en réponse 404.
3. Pourquoi utiliser les constantes Response::HTTP_* ?
Response::HTTP_* ?Pour la lisibilité et éviter les nombres magiques : Response::HTTP_CREATED dit « 201 » explicitement.
4. Où doit vivre la logique métier, et pas le contrôleur ?
Dans des services (Partie 5). Le contrôleur reste fin : lire l’entrée, déléguer, renvoyer une réponse.
5. Comment un contrôleur obtient-il un service dont il a besoin ?
Par injection : en le déclarant comme argument de la méthode (autowiring) ou du constructeur. Il ne fait pas new.
Prochain chapitre : 4.4 — Value resolvers — #[MapEntity], #[MapRequestPayload], #[MapQueryParameter] : transformer automatiquement la requête en objets typés.