Chapitre 4.4 — Value resolvers : de la requête aux objets typés
Partie 4 : Cœur HTTP — Chapitre 4/6 Version de référence : Symfony 7.4.
⏱️ TL;DR
- Les value resolvers transforment automatiquement la requête en arguments typés du contrôleur. Vous déclarez ce que vous voulez ; Symfony le fournit.
#[MapEntity]: charge une entité Doctrine depuis un paramètre d’URL (/bookings/{id}→Booking $booking). Automatique si le type est une entité et que{id}correspond.#[MapRequestPayload]: désérialise + valide le corps JSON dans un DTO. Échec de validation → 422 automatique.#[MapQueryString]/#[MapQueryParameter]: mappent la query string dans un DTO ou des scalaires (?page=2&sort=asc).#[CurrentUser]: injecte l’utilisateur authentifié.- Pour un dev Next.js : c’est le
zod.parse(await req.json())+ le « fetch de l’entité » intégrés au framework, déclenchés par la signature de votre fonction.- ⚡ Remplace l’ancien ParamConverter (SensioFrameworkExtraBundle) de l’ère Symfony 5.
🎯 Objectifs
- Charger une entité depuis l’URL sans code de récupération manuel.
- Recevoir un DTO validé directement depuis le corps JSON.
- Mapper la query string proprement.
- Comprendre qu’on peut écrire son propre resolver.
1. Le principe : l’ArgumentResolver
Avant d’appeler votre contrôleur, Symfony doit remplir ses arguments. Pour chaque paramètre, il interroge une chaîne de value resolvers : « sais-tu fournir une valeur pour Booking $booking ? pour int $id ? pour CreateBookingInput $input ? ». Le premier resolver compétent répond.
Certains resolvers sont implicites (un service par son type, un paramètre de route par son nom) ; d’autres s’activent par attribut (#[MapEntity], #[MapRequestPayload]).
2. #[MapEntity] : charger une entité depuis l’URL
Le cas le plus fréquent : une route /bookings/{id} et vous voulez l’entité Booking, pas juste l’int. Le EntityValueResolver s’en charge — souvent sans même l’attribut, par convention :
use App\Entity\Booking;
// Le {id} de l'URL sert à charger le Booking par sa clé primaire.
#[Route('/bookings/{id<\d+>}', name: 'booking_show', methods: ['GET'])]
public function show(Booking $booking): Response // ← chargé automatiquement, 404 si absent
{
return $this->json($booking);
}Si aucun Booking n’a cet id, Symfony lève une 404 automatiquement. Pour charger par un autre champ (ex. un slug) ou une expression, on précise via #[MapEntity] :
use Symfony\Bridge\Doctrine\Attribute\MapEntity;
#[Route('/providers/{slug}', name: 'provider_show', methods: ['GET'])]
public function show(
#[MapEntity(mapping: ['slug' => 'slug'])] Provider $provider
): Response {
return $this->json($provider);
}
// Charger via une expression (ex. dernière réservation d'un utilisateur) :
#[Route('/users/{userId}/last-booking')]
public function lastBooking(
#[MapEntity(expr: 'repository.findLastForUser(userId)')] Booking $booking
): Response { /* ... */ }⚡ Depuis Symfony 5 : ceci remplace le
@ParamConverterduSensioFrameworkExtraBundle(que vous connaissiez peut-être). Le resolver d’entité est désormais natif (Doctrine bridge), plus rapide et sans bundle tiers. Si vous cherchezParamConverter, son remplaçant est#[MapEntity].
⚠️ Piège :
#[MapEntity]charge une entité par requête HTTP à sa clé. Pour des chargements complexes (jointures, filtres, sécurité fine), préférez un repository appelé depuis un service. Le resolver est parfait pour le cas simple « une URL → une entité » ; ne cherchez pas à y mettre de la logique.
3. #[MapRequestPayload] : DTO validé depuis le corps JSON
Le résolveur premium pour une API. Il désérialise le corps JSON dans un DTO et le valide (via les attributs #[Assert\...]). En cas d’échec, il renvoie 422 automatiquement, sans une ligne de code de votre part.
// src/Dto/CreateBookingInput.php
declare(strict_types=1);
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateBookingInput
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Email]
public string $customerEmail,
#[Assert\NotNull]
public \DateTimeImmutable $start,
#[Assert\Positive]
public int $seats = 1,
) {}
}// dans le contrôleur
use App\Dto\CreateBookingInput;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
#[Route('/api/bookings', name: 'api_booking_create', methods: ['POST'])]
public function create(
#[MapRequestPayload] CreateBookingInput $input, // décodé + validé automatiquement
BookingCreator $creator,
): JsonResponse {
$booking = $creator->create($input); // $input est sûr et typé
return $this->json($booking, 201);
}Envoyez POST /api/bookings avec {"customerEmail":"a@b.c","start":"2026-08-01T10:00:00Z","seats":2} : Symfony construit le DTO, valide, et si l’e-mail est vide ou mal formé, répond 422 avec le détail des violations — avant même d’entrer dans votre méthode.
💡 Pour un dev Next.js : c’est exactement votre pattern
const input = schema.parse(await req.json()), mais déclaratif et intégré : le schéma, ce sont les attributs#[Assert\...]sur le DTO ; leparse, c’est#[MapRequestPayload]; l’erreur de validation devient un 422 structuré automatiquement. Vous « validez aux frontières » sans écrire la plomberie.
On peut préciser des groupes de validation, le format attendu, ou le code d’erreur :
#[MapRequestPayload(
validationGroups: ['create'],
acceptFormat: 'json',
)]
CreateBookingInput $input4. Query string : #[MapQueryString] et #[MapQueryParameter]
Pour les paramètres d’URL (?page=2&sort=asc), deux options :
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
// Un paramètre scalaire à la fois :
#[Route('/api/bookings', methods: ['GET'])]
public function list(
#[MapQueryParameter] int $page = 1,
#[MapQueryParameter] string $sort = 'asc',
): JsonResponse { /* ?page=2&sort=desc */ }
// Ou tout un objet de critères (DTO) validé :
#[Route('/api/bookings', methods: ['GET'])]
public function search(#[MapQueryString] BookingQuery $query): JsonResponse { /* ... */ }#[MapQueryParameter] extrait et caste un paramètre (avec sa valeur par défaut) ; #[MapQueryString] remplit un DTO entier depuis la query string (idéal pour des filtres riches, avec validation).
5. #[CurrentUser] et l’utilisateur authentifié
use App\Entity\User;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
#[Route('/api/me', methods: ['GET'])]
public function me(#[CurrentUser] User $user): JsonResponse
{
return $this->json(['email' => $user->getEmail()]);
}#[CurrentUser] injecte l’utilisateur connecté (détails de sécurité en Partie 10). Sans utilisateur, l’argument est null (ou déclenche une 401 selon la config).
6. Écrire son propre resolver (aperçu)
Pour des cas sur mesure (injecter un objet « contexte de tenant », par ex.), on implémente ValueResolverInterface :
final class TenantValueResolver implements ValueResolverInterface
{
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
if ($argument->getType() !== Tenant::class) {
return []; // pas concerné
}
yield $this->tenantContext->getCurrentTenant();
}
}On y reviendra pour le multi-tenant (Partie 15). Retenez que le mécanisme est extensible : tout ce qui arrive dans vos contrôleurs peut être résolu proprement.
📚 Aller plus loin : la doc « Controller — Value Resolvers » liste tous les resolvers natifs (
#[MapUploadedFile],#[MapDateTime]…) et explique comment écrire les vôtres.
✏️ Exercices
Exercice 1. Réécrivez cette action pour charger le Booking automatiquement plutôt qu’à la main :
#[Route('/bookings/{id<\d+>}', methods: ['GET'])]
public function show(int $id, BookingRepository $repo): Response
{
$booking = $repo->find($id);
if (!$booking) { throw $this->createNotFoundException(); }
return $this->json($booking);
}✅ Solution
#[Route('/bookings/{id<\d+>}', methods: ['GET'])]
public function show(Booking $booking): Response // EntityValueResolver + 404 auto
{
return $this->json($booking);
}Le resolver d’entité charge Booking par {id} et lève une 404 si introuvable — plus besoin du repository ni du test manuel.
Exercice 2. Créez un endpoint POST /api/providers recevant un DTO CreateProviderInput (champs name non vide, email valide) validé automatiquement, et renvoyant 201.
✅ Solution
final readonly class CreateProviderInput
{
public function __construct(
#[Assert\NotBlank] public string $name,
#[Assert\NotBlank] #[Assert\Email] public string $email,
) {}
}
#[Route('/api/providers', methods: ['POST'])]
public function create(
#[MapRequestPayload] CreateProviderInput $input,
ProviderCreator $creator,
): JsonResponse {
$provider = $creator->create($input);
return $this->json($provider, 201);
}En cas de name vide ou email invalide, #[MapRequestPayload] renvoie automatiquement un 422 détaillé.
Exercice 3. Quel attribut correspond à #[MapRequestPayload] dans votre vocabulaire Next.js/Zod, et quelle est la différence de « qui fait quoi » ?
✅ Solution
#[MapRequestPayload] ≈ schema.parse(await req.json()) de Zod. Différence : en Zod, vous écrivez le parse dans le handler ; en Symfony, c’est déclaratif — le DTO porte le schéma (attributs #[Assert\...]) et le framework effectue décodage + validation avant d’entrer dans le contrôleur, et transforme un échec en 422 structuré sans code de votre part.
🧠 Quiz de révision
1. À quoi servent les value resolvers ?
À remplir automatiquement les arguments d’un contrôleur à partir de la requête (paramètre de route, entité, DTO validé, utilisateur, service…).
2. Comment charger une entité Doctrine depuis /bookings/{id} ?
/bookings/{id} ?En typant l’argument avec l’entité : public function show(Booking $booking). Le EntityValueResolver la charge par {id} (404 si absente) ; #[MapEntity] permet de personnaliser (autre champ, expression).
3. Que fait #[MapRequestPayload] et que se passe-t-il si la validation échoue ?
#[MapRequestPayload] et que se passe-t-il si la validation échoue ?Il désérialise le corps JSON dans un DTO et le valide (attributs #[Assert\...]). En cas d’échec, il renvoie automatiquement une réponse 422 détaillée, sans atteindre le contrôleur.
4. Quel attribut remplace l’ancien @ParamConverter ?
@ParamConverter ?#[MapEntity] (resolver d’entité natif du Doctrine bridge), qui rend le SensioFrameworkExtraBundle inutile pour ce besoin.
5. Comment récupère-t-on la query string sous forme d’objet ?
Avec #[MapQueryString] (remplit un DTO depuis ?a=1&b=2) ou #[MapQueryParameter] (un scalaire à la fois).
Prochain chapitre : 4.5 — Sessions & flash — état entre requêtes, messages flash, cookies.