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
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
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
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
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.