Forty lines of $request->get() before the first line of real logic. That was the controller nobody wanted to touch, and the Symfony 6.1 upgrade was my excuse to open it.

An action that reads ten request fields by hand does two jobs. It translates HTTP into data, and it runs the use case. The first job is boring and repeated in every action, which is exactly why it should not be written by hand ten times. Symfony has argument resolvers for this since 3.1. People just do not use them for their own types.

The idea: declare a DTO as the action argument, teach a resolver to build it.

final class CreateOrderRequest
{
    public function __construct(
        #[Assert\NotBlank] public readonly string $customerId,
        #[Assert\Count(min: 1)] public readonly array $items,
    ) {}
}

public function create(CreateOrderRequest $request): Response
{
    // only the use case here
}

The resolver implements ArgumentValueResolverInterface, decodes the JSON body, constructs the DTO, runs the validator, and throws a 422 with the violation list if the input is bad. Written once, about fifty lines. Every action after that gets a typed, validated object for free. The controller shrinks to one call into the application layer, and the question “is this field a string or null here” has one answer in one place.

The trap comes later. I watched a colleague walk into it last month. The resolver starts loading entities. Then checking permissions. Then calling an external service, because “the data is needed anyway”. Stop. A resolver translates the request and nothing else. If it queries half the system, your use case now runs before the controller, in a place where nobody looks for it.

My rule: a resolver may see the request and the validator. If it needs a repository, that lookup belongs to the handler.

I broke this rule myself once, for one small lookup. It stayed small for two weeks.