Symfony controllers can map request data directly into typed PHP objects using attributes such as #[MapRequestPayload] and #[MapQueryString]. This removes most of the boilerplate involved in request parsing and validation. Symfony 8.1 further improves this feature with four new additions.

Mapping Uploaded Files into DTOs

Jonathan
Contributed by Jonathan in #62925

Until now, DTOs containing both scalar fields and uploaded files could not be populated through #[MapRequestPayload]. Developers had to either split the controller into separate argument resolvers or manually merge $request->files into the data array before deserialization.

In Symfony 8.1, #[MapRequestPayload] handles multipart/form-data requests by merging request parameters and uploaded files, including nested arrays, before deserialization. This means that an UploadedFile property is populated transparently:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use Symfony\Component\HttpFoundation\File\UploadedFile;

class ProductDto
{
    public ?string $name = null;
    public ?UploadedFile $image = null;
}

class ProductController
{
    public function upload(
        #[MapRequestPayload] ProductDto $data,
    ): Response {
        // $data->name comes from the form fields
        // $data->image is an UploadedFile instance
    }
}

Variadic Controller Arguments

Djordy Koert
Contributed by Djordy Koert in #54817

Mapping an array of DTOs previously required a typed array parameter together with the type option. Symfony 8.1 adds support for variadic parameters, which is more idiomatic in PHP:

1
2
3
4
5
6
7
8
9
10
class ProductController
{
    public function createPrices(
        #[MapRequestPayload] Price ...$prices,
    ): Response {
        foreach ($prices as $price) {
            // ...
        }
    }
}

With a JSON payload such as [{"value": 50}, {"value": 23}], $prices is unpacked into a sequence of Price instances. The same syntax also works with #[MapQueryString] and #[MapUploadedFile].

Mapping Empty Payloads

Jeroen Spee
Contributed by Jeroen Spee in #52134

By default, an empty query string or request body short-circuits to null on a nullable parameter without invoking the Serializer. This prevents custom denormalizers from injecting values from the security context, session, or other sources into the DTO.

Symfony 8.1 introduces a mapWhenEmpty option for #[MapRequestPayload] and #[MapQueryString]. When set to true, denormalization runs even on empty input. In this case, the denormalizer receives an empty array, giving custom denormalizers a chance to populate the DTO:

1
2
3
4
5
6
7
8
9
10
class SearchController
{
    public function search(
        #[MapQueryString(mapWhenEmpty: true)] SearchFilters $filters,
    ): Response {
        // denormalization runs even with an empty query string,
        // so a custom denormalizer can e.g. inject $filters->userId
        // from the security context
    }
}

Dynamic Validation Groups

Adrian Brajković
Contributed by Adrian Brajković in #58273

The validationGroups option of #[MapRequestPayload] and #[MapQueryString] previously accepted only static strings. As soon as validation groups depended on resolved request arguments, such as applying stricter rules for admins than for regular users, you had to bypass the mapper and call the validator manually.

Symfony 8.1 now allows passing an Expression or a Closure to validationGroups. Both are evaluated at validation time, and an args variable provides access to all resolved controller arguments:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

class UserController
{
    #[Route('/users/{user}', methods: ['PUT'])]
    public function update(
        User $user,
        #[MapRequestPayload(
            validationGroups: [new Expression('args["user"].getType()')],
        )] UpdateUserDto $dto,
    ): Response {
        // Validation groups are computed from the resolved $user
    }
}

A Closure works the same way and receives the controller arguments as a single array. This is useful when the logic does not fit on a single line.

Published in #Living on the edge