Controllers — argument resolvers, attribute injection, invokables
TL;DR — Un controller Symfony moderne = une méthode pure dont les arguments sont injectés automatiquement par les ArgumentResolvers.
#[MapRequestPayload](6.3+) désérialise+valide le body JSON en DTO ;#[MapQueryString]fait pareil pour les query strings ;#[CurrentUser]injecte l'utilisateur courant. Tu peux retournerResponse, ou un objet et laisser lekernel.viewlistener serializer.AbstractControllerest un sucre pratique mais pas obligatoire.
🧠 Mental model — ASCII diagram + analogie
kernel.controller event
│
▼
ArgumentResolver::getArguments($request, $controller)
│
▼
┌──────────────────────────────────────────────────────────┐
│ For each parameter, find a ValueResolver that supports() │
│ │
│ - RequestValueResolver ($request) │
│ - RequestAttributeValueResolver (#[Route] params) │
│ - SessionValueResolver (SessionInterface) │
│ - SecurityTokenValueResolver (#[CurrentUser]) │
│ - EntityValueResolver (Doctrine entity) │
│ - RequestPayloadValueResolver (#[MapRequestPayload]) │
│ - QueryParameterValueResolver (#[MapQueryParameter]) │
│ - BackedEnumValueResolver (enum from route) │
│ - DateTimeValueResolver (DateTimeInterface) │
│ - DefaultValueResolver (default param value) │
│ - ServiceValueResolver (service from DI) │
└──────────────────────────────────────────────────────────┘
│
▼
call_user_func_array($controller, $resolvedArgs) → Response|mixedAnalogie : ton controller est un acteur sur scène, l'ArgumentResolver est le régisseur qui lui tend les bons accessoires avant l'entrée. Tu écris ton rôle (signature de méthode), le régisseur s'occupe de te livrer l'épée, le chapeau, le manuscrit — chacun via un fournisseur spécialisé (resolver).
Comment un staff engineer raisonne sur le controller
Un controller n'est pas un endroit où l'on met de la logique. C'est un adaptateur : il traduit HTTP (un protocole texte, sans types, hostile) vers le domaine (typé, validé, pur), puis retraduit le résultat du domaine vers HTTP. Toute ligne de code dans un controller qui n'est pas de la traduction est une dette : elle n'est ni réutilisable hors HTTP (CLI, queue, cron) ni testable sans booter un kernel.
La règle mentale : un controller doit pouvoir être lu en 5 secondes et résumé en une phrase. « Reçois un InitiateTransferRequest, délègue à l'orchestrator, renvoie 201/422. » Si tu ne peux pas le résumer ainsi, de la logique a fuité dedans.
| Responsabilité | Appartient au controller ? | Où ça vit sinon |
|---|---|---|
| Désérialiser le body | Non (délégué au resolver) | RequestPayloadValueResolver |
| Valider la forme du payload | Non | Constraints sur le DTO |
| Autorisation grossière (rôle) | Déclaratif (#[IsGranted]) | Voter |
| Autorisation fine (ownership) | Déclaratif (#[IsGranted('VIEW', 'subject')]) | Voter |
| Règle métier | Jamais | Service / use-case / handler |
| Accès base de données | Jamais directement | Repository / service |
| Mapping résultat → HTTP status | Oui (c'est de la traduction) | — |
| Choix du Content-Type / headers | Oui | — |
Le pipeline complet : où le controller se situe dans le HttpKernel
Le controller n'est qu'une étape d'un pipeline événementiel. Connaître l'ordre exact est ce qui sépare le dev qui « débugge au hasard » du dev qui sait où poser un breakpoint :
Request
│
├─ kernel.request → routing, firewall (auth), locale (peut court-circuiter → Response)
│
├─ kernel.controller → résolution du callable, _controller (peut remplacer le controller)
│
├─ ArgumentResolver → construit les arguments (resolvers) (#[MapRequestPayload] s'exécute ICI)
│
├─ kernel.controller_arguments → dernière chance de muter args
│
├─ ╔══════════════════╗
│ ║ TON CONTROLLER ║ → retourne Response | mixed
│ ╚══════════════════╝
│
├─ kernel.view → SI mixed (pas Response) : convertit en Response (ex: serializer listener)
│
├─ kernel.response → modifie la Response (headers, cookies, cache)
│
└─ kernel.terminate → après envoi au client (emails, logs) ← idéal pour le travail "fire and forget"Conséquence directe : une exception levée dans #[MapRequestPayload] (validation) se produit avant ton controller — d'où le 422 « automatique ». Ce n'est pas magique : RequestPayloadValueResolver lève une HttpException(422) que le kernel.exception listener transforme en réponse. Tu peux donc customiser le format d'erreur (RFC 7807) globalement via un ExceptionListener, sans toucher un seul controller.
🛠️ Code minimal — controller moderne avec attributes
// src/Controller/UserController.php
<?php
namespace App\Controller;
use App\Dto\CreateUserDto;
use App\Dto\UserListQuery;
use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapEntity;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/api/users', name: 'api_user_')]
final class UserController extends AbstractController
{
public function __construct(
private readonly UserRepository $users,
) {}
#[Route('', name: 'list', methods: ['GET'])]
public function list(
#[MapQueryString] UserListQuery $query = new UserListQuery(),
): JsonResponse {
$users = $this->users->search($query);
return $this->json($users, context: ['groups' => ['user:read']]);
}
#[Route('', name: 'create', methods: ['POST'])]
#[IsGranted('ROLE_ADMIN')]
public function create(
#[MapRequestPayload(acceptFormat: 'json', validationGroups: ['create'])]
CreateUserDto $dto,
): JsonResponse {
$user = $this->users->createFromDto($dto);
return $this->json($user, Response::HTTP_CREATED, context: ['groups' => ['user:read']]);
}
#[Route('/{id}', name: 'show', methods: ['GET'], requirements: ['id' => '\d+'])]
public function show(
#[MapEntity(mapping: ['id' => 'id'])] User $user,
#[CurrentUser] ?User $currentUser,
): JsonResponse {
if ($currentUser?->getId() !== $user->getId() && !$this->isGranted('ROLE_ADMIN')) {
throw $this->createAccessDeniedException();
}
return $this->json($user, context: ['groups' => ['user:read']]);
}
}// src/Dto/CreateUserDto.php
<?php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class CreateUserDto
{
public function __construct(
#[Assert\NotBlank(groups: ['create'])]
#[Assert\Email]
public string $email,
#[Assert\NotBlank(groups: ['create'])]
#[Assert\Length(min: 8, max: 72)]
public string $password,
#[Assert\Choice(choices: ['admin', 'user', 'guest'])]
public string $role = 'user',
) {}
}// src/Dto/UserListQuery.php
<?php
namespace App\Dto;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class UserListQuery
{
public function __construct(
#[Assert\Positive]
public int $page = 1,
#[Assert\Range(min: 1, max: 100)]
public int $limit = 20,
#[Assert\Choice(['email', 'createdAt'])]
public string $sort = 'createdAt',
public ?string $search = null,
) {}
}Controller invokable :
// src/Controller/HomeController.php
#[Route('/', name: 'home', methods: ['GET'])]
final class HomeController extends AbstractController
{
public function __invoke(): Response
{
return $this->render('home.html.twig');
}
}ArgumentResolver custom :
// src/ArgumentResolver/ClientIpResolver.php
<?php
namespace App\ArgumentResolver;
use App\Value\ClientIp;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
final class ClientIpResolver implements ValueResolverInterface
{
public function resolve(Request $request, ArgumentMetadata $argument): iterable
{
if ($argument->getType() !== ClientIp::class) {
return [];
}
yield new ClientIp($request->getClientIp() ?? '0.0.0.0');
}
}🎯 Patterns courants
#[MapRequestPayload](6.3+) — désérialize body JSON/XML + valide via constraints PHP attributes. Plus besoin de form pour les API JSON. ThrowHttpException 422auto si validation fail.#[MapQueryString]— idem pour la query string :?page=2&limit=50→ DTO typé.#[MapEntity]— remplace l'ancien@ParamConverterDoctrine.#[MapEntity(mapping: ['slug' => 'slug'])]ou simplement type-hint l'entity → Doctrine la fetch par PK.#[CurrentUser]— injecte directementUser|nullau lieu de$this->getUser(). Plus testable, plus explicite.- DTO immutable —
final readonly classPHP 8.2, constructor avec constraints. Le DTO traverse le controller, le service, jamais muté → safe en worker mode. - Invokable controller —
__invoke()pour un single-action controller. Combine avec#[AsController]si tu veux contrôler le service ID, ou enregistrement DI manuel.
🔄 Versions — Symfony 5.4 / 6.4 / 7.x
- 5.4 : ArgumentResolver système stable, mais
#[MapRequestPayload]n'existe pas encore. Tu utilises Form ou Serializer manuellement.@ParamConverter(SensioFrameworkExtraBundle) toujours là. - 6.0 :
#[CurrentUser]migre vers le composant Security core.SensioFrameworkExtraBundleannotations deprecated → Symfony native attributes. - 6.1 : nouveau
ValueResolverInterface(remplaceArgumentValueResolverInterface). Génération viaiterable yieldau lieu d'array. - 6.2 :
#[MapQueryParameter](single param),#[MapEntity](remplace ParamConverter),BackedEnumValueResolvernatif. - 6.3 : 🌟
#[MapRequestPayload]+#[MapQueryString]— game changer pour les APIs. Serializer + Validator intégrés. - 6.4 LTS :
#[MapRequestPayload(acceptFormat: ['json', 'xml'])],validationGroups: [...],validationFailedStatusCode: 400. - 7.0 : suppression définitive de
ArgumentValueResolverInterfacelegacy → tu dois utiliserValueResolverInterface. Suppression@ParamConverter(SensioFEB obsolète). - 7.1+ : amélioration
#[MapRequestPayload]pour les nested DTOs, support de DTO génériques.
⚠️ Pitfalls
AbstractControllercouplage — pratique mais te lie au framework. Pour code testable et portable, fais des controllers POPO avec services injectés via constructeur.#[MapRequestPayload]sans Content-Type — si le client envoie du JSON sansContent-Type: application/json, le resolver pense que c'est form-encoded → erreur cryptique. Toujours vérifier le header côté client.- DTO mutable — propriétés
publicnon-readonly + setter = state leak en worker mode si tu mémoïses. Toujoursreadonly. - Retour de Response oublié — un controller qui retourne
void→ exception "controller must return Response object" (sauf si unkernel.viewlistener convertit, mais alors voulu). $this->getUser()typedUserInterface|null— toujours null-check. Préfère#[CurrentUser] ?User $userpour le typage strict de ta propre entité.#[IsGranted]au niveau classe vs méthode — au niveau classe, s'applique avant toute méthode. Combine prudemment :#[IsGranted('ROLE_USER')]classe +#[IsGranted('ROLE_ADMIN')]méthode = ADMIN requis sur cette méthode.- Route+method+controller mismatch —
methods: ['GET']sur route mais formulairePOSTen Twig → 405 Method Not Allowed. Lire le Symfony Profiler "Request" panel. - ArgumentResolver custom non-priorisé — un resolver custom qui supporte un type générique peut shadowe les built-in. Toujours définir
prioritydans le tagcontroller.argument_value_resolver.
🧪 Testing
// tests/Controller/UserControllerTest.php
<?php
namespace App\Tests\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class UserControllerTest extends WebTestCase
{
public function testListReturnsJson(): void
{
$client = static::createClient();
$client->request('GET', '/api/users?limit=5&page=1');
self::assertResponseIsSuccessful();
self::assertResponseHeaderSame('content-type', 'application/json');
}
public function testCreateWithInvalidPayloadReturns422(): void
{
$client = static::createClient();
$client->loginUser(self::getAdminUser());
$client->request('POST', '/api/users', server: [
'CONTENT_TYPE' => 'application/json',
], content: json_encode(['email' => 'not-an-email', 'password' => 'short']));
self::assertResponseStatusCodeSame(422);
}
public function testCreateSucceeds(): void
{
$client = static::createClient();
$client->loginUser(self::getAdminUser());
$client->request('POST', '/api/users', server: [
'CONTENT_TYPE' => 'application/json',
], content: json_encode([
'email' => '[email protected]',
'password' => 'secret123',
'role' => 'user',
]));
self::assertResponseStatusCodeSame(201);
}
private static function getAdminUser(): User
{
return static::getContainer()->get('doctrine')
->getRepository(User::class)
->findOneBy(['email' => '[email protected]']);
}
}Tester un argument resolver isolément :
$resolver = new ClientIpResolver();
$request = Request::create('/', server: ['REMOTE_ADDR' => '1.2.3.4']);
$arg = new ArgumentMetadata('ip', ClientIp::class, isVariadic: false, hasDefaultValue: false, defaultValue: null);
$result = iterator_to_array($resolver->resolve($request, $arg));
self::assertEquals(new ClientIp('1.2.3.4'), $result[0]);🎬 Cas d'usage concrets
Scénario 1 — Banque (Société Générale-like) : API d'initiation de virement avec DTO immutable
Contexte : la banque expose une API interne consommée par l'app mobile pour initier un virement SEPA. Le payload contient : IBAN bénéficiaire, montant, motif, date d'exécution. La compliance impose : (1) validation forte côté serveur, (2) idempotency-key obligatoire pour éviter doubles débits sur retry mobile, (3) audit trail systématique, (4) réponse RFC 7807 structurée en cas d'erreur.
Le controller utilise #[MapRequestPayload] qui désérialise + valide le JSON en InitiateTransferDto, un #[MapQueryString] pour dryRun=true|false, le #[CurrentUser] BankCustomer pour l'utilisateur authentifié. Aucun appel $request->toArray() manuel — toute la chaîne est typée. Le controller délègue immédiatement à un TransferOrchestrator (CQRS command bus) et retourne un DTO de réponse sérialisé en JSON.
Bénéfice : le controller fait 12 lignes, la validation est centralisée dans le DTO, et un retry mobile avec même idempotency-key retourne la même réponse sans rejouer le débit.
Scénario 2 — Cabinet juridique (Lexisnexis-like) : formulaire intake client multi-étapes
Contexte : un cabinet d'avocats utilise un formulaire d'intake en 4 étapes (identité, nature du litige, pièces justificatives, accord d'honoraires). Les données sont sauvegardées progressivement en session pour permettre la reprise sur un autre device, et la dernière étape génère une fiche client + un dossier Doctrine.
Le IntakeController étend AbstractController pour le sucre createForm, addFlash, render. Chaque étape a sa route avec name: intake_step_X et son FormType dédié. Le controller route les étapes via un IntakeStateMachine (workflow Symfony). $this->isCsrfTokenValid() n'est jamais appelé directement — le component Form le fait pour nous. Côté UX, un kernel.exception listener convertit toute exception en redirect vers l'étape 1 avec flash message — pas de stack trace pour le client final.
Effet net : 40% de conversions complétées (vs 25% avec ancien formulaire monolithique), et 0 ligne de validation custom dans le controller — tout est délégué au form + validator.
Scénario 3 — Marketplace e-commerce (Mirakl-like) : controllers thin, command bus Messenger derrière
Contexte : marketplace B2B où chaque vendor pousse son catalogue via API. Le endpoint POST /api/v2/vendors/{vendorId}/products peut recevoir un payload de 50 MB (jusqu'à 10 000 SKUs par batch). Synchrone impossible (timeout, mémoire).
Le controller fait uniquement : (1) #[MapRequestPayload] PushCatalogDto $dto, (2) #[MapEntity] Vendor $vendor (lookup par PK + Voter d'autorisation via #[IsGranted('VENDOR_PUSH', 'vendor')]), (3) dispatch d'un PushCatalogCommand sur le bus Messenger async, (4) retourne 202 Accepted avec le jobId.
Note perf 50 MB : un payload de cette taille ne doit pas passer par
#[MapRequestPayload](le Serializer charge tout en mémoire — 50 MB de JSON ⇒ ~250-500 MB d'objets PHP). En réalité on stream le body brut vers un object storage (S3) et on ne désérialise qu'un manifeste léger ({ "objectKey": "...", "sku_count": 9821 }) dans le DTO. Le handler async lit le fichier en streaming (JsonMachine, parser SAX) ligne par ligne. Le controller ne voit jamais les 10 000 SKUs. Tout le boulot lourd (parsing CSV, validation, écriture base) tourne dans unMessageHandlerInterfaceasync, scaled horizontalement par Kubernetes HPA.
Bénéfice : le controller est testé en 3 lignes (vérifier que le command est dispatché), et la charge ne sature plus les workers HTTP. Les retries Messenger gèrent les échecs transient.
🛠️ Exemple end-to-end
Use case : API d'initiation de virement banque avec DTO typé, validation strict, idempotency, et delegation à un orchestrator.
// src/Banking/Dto/InitiateTransferRequest.php
<?php
declare(strict_types=1);
namespace App\Banking\Dto;
use Money\Money;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class InitiateTransferRequest
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Iban]
public string $beneficiaryIban,
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 70)]
public string $beneficiaryName,
#[Assert\NotBlank]
#[Assert\Positive]
#[Assert\Range(min: 1, max: 50_000_00)]
public int $amountInCents,
#[Assert\NotBlank]
#[Assert\Length(max: 140)]
public string $purpose,
#[Assert\NotNull]
#[Assert\GreaterThanOrEqual('today')]
public \DateTimeImmutable $executionDate,
#[Assert\NotBlank]
#[Assert\Uuid]
public string $idempotencyKey,
) {}
public function amount(): Money
{
return Money::EUR($this->amountInCents);
}
}// src/Banking/Dto/InitiateTransferResponse.php
<?php
declare(strict_types=1);
namespace App\Banking\Dto;
final readonly class InitiateTransferResponse
{
public function __construct(
public string $transferId,
public TransferStatus $status,
public \DateTimeImmutable $estimatedExecutionAt,
) {}
}
enum TransferStatus: string
{
case Pending = 'pending';
case Scheduled = 'scheduled';
case Rejected = 'rejected';
}// src/Controller/Api/V2/TransferController.php
<?php
declare(strict_types=1);
namespace App\Controller\Api\V2;
use App\Banking\Dto\InitiateTransferRequest;
use App\Banking\Dto\InitiateTransferResponse;
use App\Banking\Orchestration\TransferOrchestrator;
use App\Banking\Security\BankCustomer;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
#[Route('/api/v2/transfers', name: 'api_v2_transfer_')]
#[IsGranted('ROLE_BANK_CUSTOMER')]
final class TransferController
{
public function __construct(
private readonly TransferOrchestrator $orchestrator,
private readonly NormalizerInterface $normalizer,
) {}
#[Route('', name: 'initiate', methods: ['POST'])]
public function initiate(
#[MapRequestPayload] InitiateTransferRequest $request,
#[CurrentUser] BankCustomer $customer,
#[MapQueryParameter] bool $dryRun = false,
): JsonResponse {
$result = $this->orchestrator->initiate(
customer: $customer,
request: $request,
dryRun: $dryRun,
);
$payload = $this->normalizer->normalize($result, 'json', [
'groups' => ['transfer:read'],
]);
return new JsonResponse(
data: $payload,
status: $result->status === TransferStatus::Rejected ? 422 : 201,
headers: ['Location' => "/api/v2/transfers/{$result->transferId}"],
);
}
}// src/Banking/Orchestration/TransferOrchestrator.php
<?php
declare(strict_types=1);
namespace App\Banking\Orchestration;
use App\Banking\Dto\InitiateTransferRequest;
use App\Banking\Dto\InitiateTransferResponse;
use App\Banking\Dto\TransferStatus;
use App\Banking\Repository\IdempotencyRepository;
use App\Banking\Security\BankCustomer;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Uid\Ulid;
final readonly class TransferOrchestrator
{
public function __construct(
private MessageBusInterface $commandBus,
private IdempotencyRepository $idempotency,
) {}
public function initiate(
BankCustomer $customer,
InitiateTransferRequest $request,
bool $dryRun,
): InitiateTransferResponse {
if ($cached = $this->idempotency->find($customer->id(), $request->idempotencyKey)) {
return $cached;
}
$transferId = (string) new Ulid();
$response = new InitiateTransferResponse(
transferId: $transferId,
status: $dryRun ? TransferStatus::Pending : TransferStatus::Scheduled,
estimatedExecutionAt: $request->executionDate,
);
if (!$dryRun) {
$this->commandBus->dispatch(new ExecuteTransferCommand(
transferId: $transferId,
customerId: $customer->id(),
request: $request,
));
}
$this->idempotency->store($customer->id(), $request->idempotencyKey, $response);
return $response;
}
}Le controller fait 15 lignes utiles. Toute la complexité est dans le DTO (validation déclarative), l'orchestrator (idempotency), le command bus (async execution). Un retry mobile sur l'app banque avec même idempotencyKey retourne la réponse mémoïsée — pas de double virement.
🏭 Production — erreurs RFC 7807, observabilité, sécurité, perf
Erreurs structurées (RFC 7807) sans polluer les controllers
#[MapRequestPayload] lève une UnprocessableEntityHttpException portant une ValidationFailedException en previous. Un seul listener la transforme en application/problem+json pour toute l'API — les controllers restent vierges de gestion d'erreur.
// src/EventListener/ProblemDetailsListener.php
<?php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Symfony\Component\Validator\Exception\ValidationFailedException;
#[AsEventListener(event: 'kernel.exception', priority: 64)]
final class ProblemDetailsListener
{
public function __invoke(ExceptionEvent $event): void
{
$request = $event->getRequest();
// N'intercepte que l'API ; laisse le profiler/HTML web tranquille.
if (!str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
$e = $event->getThrowable();
$status = $e instanceof HttpExceptionInterface ? $e->getStatusCode() : 500;
$problem = [
'type' => "https://httpstatuses.com/{$status}",
'title' => $status >= 500 ? 'Internal Server Error' : $e->getMessage(),
'status' => $status,
'traceId' => $request->headers->get('X-Request-Id'),
];
// Détaille les violations de validation, sans fuiter d'info en 500.
$previous = $e->getPrevious();
if ($previous instanceof ValidationFailedException) {
$problem['violations'] = array_map(
static fn ($v) => [
'field' => $v->getPropertyPath(),
'message' => $v->getMessage(),
],
iterator_to_array($previous->getViolations()),
);
}
$response = new JsonResponse($problem, $status);
$response->headers->set('Content-Type', 'application/problem+json');
$event->setResponse($response);
}
}En 500, on ne renvoie jamais
$e->getMessage()au client (fuite de chemins, requêtes SQL, secrets). On expose untraceIdcorrélable avec les logs côté serveur. C'est la frontière sécurité du controller.
Observabilité — corréler une requête de bout en bout
Le controller est l'endroit naturel pour ouvrir un span et propager le contexte (OpenTelemetry / Datadog). En pratique on le fait dans un listener kernel.controller (pour avoir le nom de la route comme operation.name) plutôt que dans chaque controller :
- Tag de route :
_route(depuis$request->attributes) → cardinalité bornée, contrairement à l'URL brute (/users/42exploserait la cardinalité métrique). Toujours grouper par route name, jamais par path. - Latence par resolver : si
p99explose sur un endpoint avec#[MapEntity], suspecte un N+1 dans la sérialisation, pas le controller. - Logs structurés : injecte un
LoggerInterface(channel dédié) et loggeroute,user_id,status,duration_ms— jamais le payload brut (PII / RGPD).
Sécurité — checklist controller
| Risque | Mitigation au niveau controller |
|---|---|
| Mass assignment | DTO readonly avec champs explicites ; jamais $request->request->all() vers une entity |
| IDOR (accès à la ressource d'autrui) | #[IsGranted('VIEW', 'subject')] + Voter, pas un simple ROLE_USER |
| CSRF (form web) | Composant Form (CSRF auto) ; API JSON stateless → token Bearer, pas de cookie de session |
| Sur-exposition de données | groups de sérialisation explicites — user:read ne doit jamais contenir password/token |
| DoS via gros body | Limite post_max_size (PHP) + validation sku_count avant tout traitement lourd |
| Injection via route param | requirements: ['id' => '\\d+'] borne le format en amont du resolver |
Perf — ce qui coûte vraiment
#[MapEntity]implicite déclenche une requête DB par paramètre. Sur un endpoint chaud, préférer charger l'agrégat complet en une requête (DTO + repository custom) plutôt que 3#[MapEntity].- Sérialisation : le
Serializerest ~10× plus lent quejson_encodedirect. Pour des endpoints ultra-chauds en lecture, unJsonResponseavec un tableau pré-construit (ou un DTO +json_encode) bat le serializer. Mesure avant d'optimiser. - Mode worker (FrankenPHP / RoadRunner) : le kernel reste en mémoire entre requêtes. Tout état mutable dans un service
controller-scope fuit d'une requête à l'autre. Les DTOreadonlyet les servicesstatelesssont une exigence, pas un style.
🏋️ Exercices
Exercice 1 — DTO + validation conditionnelle (implémentation)
Objectif : créer un endpoint POST /api/articles qui mappe le body en CreateArticleDto via #[MapRequestPayload], avec un champ publishAt obligatoire uniquement si status === 'scheduled'.
Indice/Solution : utilise un #[Assert\When(expression: "this.status === 'scheduled'", constraints: [new Assert\NotNull()])] sur publishAt. Teste qu'un payload scheduled sans publishAt renvoie 422, et qu'un draft sans publishAt passe.
Exercice 2 — ArgumentResolver custom typé (implémentation)
Objectif : écrire un #[MapPagination] qui injecte un objet Pagination(page, limit, offset) borné (limit max 100), à partir de ?page=&limit=, réutilisable dans tous les controllers de listing.
Indice/Solution : crée l'attribut #[\Attribute] MapPagination, un ValueResolver qui lit $argument->getAttributes(MapPagination::class), clampe limit avec min(100, max(1, ...)), yielde l'objet readonly. Enregistre-le (autoconfigure le tag controller.argument_value_resolver via l'interface) et vérifie qu'un limit=9999 est ramené à 100.
Exercice 3 — RFC 7807 global + traceId (production-grade)
Objectif : brancher le ProblemDetailsListener ci-dessus, générer un X-Request-Id (ULID) en kernel.request s'il est absent, le propager dans la réponse ET dans chaque ligne de log via un Processor Monolog.
Indice/Solution : listener kernel.request priorité haute → $request->headers->set('X-Request-Id', (string) new Ulid()) si absent. Un MonologProcessor injecté lit le request stack et ajoute trace_id à $record->extra. Vérifie qu'une 422 et son log partagent le même id.
Exercice 4 — Idempotency-key réelle (production-grade)
Objectif : implémenter l'idempotency de l'exemple bancaire pour de vrai : table idempotency_keys(key, customer_id, response_hash, response_body, created_at), TTL 24h, contrainte d'unicité, gestion de la course (deux requêtes simultanées même clé).
Indice/Solution : INSERT ... ON CONFLICT DO NOTHING (ou unique index + catch UniqueConstraintViolationException). Si la clé existe avec un body différent → 409 Conflict (réutilisation abusive). Si même body en cours de traitement → renvoyer 409 ou attendre (lock advisory). Teste deux POST concurrents.
Exercice 5 — Casse-puis-répare : la fuite worker mode (break-then-fix)
Objectif : introduis volontairement un bug worker-mode — un service @AsController ou un resolver qui mémoïse un résultat dans une propriété non-readonly partagée entre requêtes — puis observe la fuite, puis corrige.
Indice/Solution : ajoute private array $cache = [] dans un resolver et stocke par idempotencyKey. Sous FrankenPHP worker, la requête B voit la réponse de la requête A. Fix : déplacer le cache dans un store à durée de vie requête (RequestStack ou un cache PSR-6 avec clé scoping) ou rendre le service stateless. Reproduis avec deux requêtes successives à clés différentes.
Exercice 6 — Resolver vs built-in : la collision de priorité (break-then-fix)
Objectif : crée un ValueResolver custom qui supports() le type string (trop large) ; constate qu'il shadow RequestAttributeValueResolver et casse l'injection des params de route ; répare via la priorité.
Indice/Solution : un resolver qui yield pour tout string intercepte $slug avant le built-in. Fix : restreindre à un attribut marqueur (#[FromHeader]) au lieu du type brut, ou baisser la priorité dans le tag. Démontre que la sur-généralité d'un supports() est le piège n°1 des resolvers custom.
🎤 En entretien
Q : Pourquoi un controller ne devrait-il jamais contenir de logique métier ? R : Parce que c'est un adaptateur HTTP : la logique métier doit tourner aussi depuis une commande CLI, un message de queue ou un cron, sans booter un kernel HTTP. Logique dans le controller = logique non réutilisable et non testable unitairement (il faut un WebTestCase complet au lieu d'un test de service).
Q : #[MapRequestPayload] lève un 422 « automatiquement ». Que se passe-t-il réellement ? R : Rien de magique. Le RequestPayloadValueResolver s'exécute pendant la phase ArgumentResolver, avant le controller ; il désérialise puis valide via le Validator, et lève une UnprocessableEntityHttpException (avec la ValidationFailedException en previous) que le listener kernel.exception transforme en réponse. D'où la possibilité de customiser le format d'erreur globalement.
Q : AbstractController ou POPO injecté par constructeur — que choisis-tu et pourquoi ? R : POPO + injection par constructeur pour le code de domaine/API : testabilité, pas de couplage au container via $this->get(), dépendances explicites. AbstractController est du sucre acceptable pour des controllers web internes (render, addFlash, createForm) où le coût de portabilité est nul. La règle : explicite > implicite dès que la testabilité compte.
Q : Tu passes l'app en mode worker (FrankenPHP). Quels controllers risquent de casser ? R : Tous ceux dont un service injecté garde un état mutable entre requêtes (cache en propriété non-readonly, connexion non réinitialisée, static). Le kernel n'est plus rejoué à chaque requête, donc l'état fuit. Mitigation : services stateless, DTO readonly, et kernel.reset (ResetInterface) pour ce qui doit être vidé entre requêtes.
🔁 Quand utiliser / éviter
#[MapRequestPayload]: API JSON, RPC, mobile backend. Évite si tu as un form HTML traditionnel (utiliseFormqui gère CSRF + theming).AbstractController: projets internes, équipe Symfony. Évite si tu veux du code "framework-agnostic" pour le porter ailleurs.- DTO : tout endpoint qui prend une payload structurée. Évite pour endpoints triviaux (un seul query param) —
#[MapQueryParameter]suffit. - Custom ArgumentResolver : crée-en un quand un type métier est injecté dans ≥ 3 controllers (sinon DRY direct dans le controller). Exemples :
Pagination,ClientIp,RequestContext. - Invokable : single-action controllers (REST resource one-method). Évite si > 1 méthode (regroupe par ressource).