feature #52134 [HttpKernel] Add option to map empty data with MapQueryString and MapRequestPayload (Jeroeny)

This PR was merged into the 8.1 branch.

Discussion
----------

[HttpKernel] Add option to map empty data with `MapQueryString` and `MapRequestPayload`

| Q             | A
| ------------- | ---
| Branch?       | 8.1
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | -
| License       | MIT

When `#[MapQueryString]` or `#[MapRequestPayload]` is used on a nullable/default-valued parameter and the query string or request body is empty, the resolver short-circuits and returns `null` without ever calling the serializer. This prevents custom denormalizers from constructing the object.

This PR adds `bool $mapWhenEmpty = false` to both attributes. When `true`, the resolver passes `[]` to `denormalize()` even when no data is present, giving custom denormalizers a chance to populate the DTO.

```php
public function __construct(
    #[MapRequestPayload(mapWhenEmpty: true)] SearchFilters $filters,
) {}
```

**Use case:** a DTO where some fields come from the request and others are injected by a custom denormalizer (e.g. from the security context or session):

```php
class SearchFilters {
    public function __construct(
        public ?string $keyword = null,  // from query string
        public int $userId = 0,          // set by a custom denormalizer
    ) {}
}
```

Without `mapWhenEmpty`, an empty query string yields `null`, the denormalizer never runs. With `mapWhenEmpty: true`, denormalization proceeds and the custom denormalizer can populate `$userId`.

Commits
-------

5117bb6f178 [HttpKernel] Add argument `$mapWhenEmpty` to `MapQueryString` and `MapRequestPayload` for always attempting denormalization with empty query and request payload
This commit is contained in:
Nicolas Grekas
2026-03-17 17:21:39 +01:00
5 changed files with 72 additions and 3 deletions

View File

@@ -41,6 +41,7 @@ class MapQueryString extends ValueResolver
string $resolver = RequestPayloadValueResolver::class,
public readonly int $validationFailedStatusCode = Response::HTTP_NOT_FOUND,
public readonly ?string $key = null,
public bool $mapWhenEmpty = false,
) {
parent::__construct($resolver);
}

View File

@@ -44,6 +44,7 @@ class MapRequestPayload extends ValueResolver
string $resolver = RequestPayloadValueResolver::class,
public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY,
public readonly ?string $type = null,
public bool $mapWhenEmpty = false,
) {
parent::__construct($resolver);
}

View File

@@ -24,6 +24,7 @@ CHANGELOG
* Deprecate passing a `ControllerArgumentsEvent` to the `ViewEvent` constructor; pass a `ControllerArgumentsMetadata` instead
* Support variadic argument with `#[MapRequestPayload]`
* Add `#[Serialize]` to serialize values returned by controllers
* Add argument `$mapWhenEmpty` to `MapQueryString` and `MapRequestPayload` for always attempting denormalization with empty query and request payload
8.0
---

View File

@@ -207,7 +207,7 @@ class RequestPayloadValueResolver implements ValueResolverInterface, EventSubscr
private function mapQueryString(Request $request, ArgumentMetadata $argument, MapQueryString $attribute): ?object
{
if (!($data = $request->query->all($attribute->key)) && ($argument->isNullable() || $argument->hasDefaultValue())) {
if (!($data = $request->query->all($attribute->key)) && ($argument->isNullable() || $argument->hasDefaultValue()) && !$attribute->mapWhenEmpty) {
return null;
}
@@ -216,8 +216,12 @@ class RequestPayloadValueResolver implements ValueResolverInterface, EventSubscr
private function mapRequestPayload(Request $request, ArgumentMetadata $argument, MapRequestPayload $attribute): object|array|null
{
if ('' === ($data = $request->request->all() ?: $request->getContent()) && ($argument->isNullable() || $argument->hasDefaultValue())) {
return null;
if ('' === $data = $request->request->all() ?: $request->getContent()) {
if ($attribute->mapWhenEmpty) {
$data = [];
} elseif ($argument->isNullable() || $argument->hasDefaultValue()) {
return null;
}
}
if (null === $format = $request->getContentTypeFormat()) {

View File

@@ -165,6 +165,46 @@ class RequestPayloadValueResolverTest extends TestCase
$this->assertSame([null], $event->getArguments());
}
public function testMapQueryStringEmpty()
{
$payload = new RequestPayload(50);
$denormalizer = new RequestPayloadDenormalizer($payload);
$serializer = new Serializer([$denormalizer]);
$resolver = new RequestPayloadValueResolver($serializer);
$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
MapQueryString::class => new MapQueryString(mapWhenEmpty: true),
]);
$request = Request::create('/', 'GET');
$kernel = $this->createStub(HttpKernelInterface::class);
$arguments = $resolver->resolve($request, $argument);
$event = new ControllerArgumentsEvent($kernel, static fn () => null, $arguments, $request, HttpKernelInterface::MAIN_REQUEST);
$resolver->onKernelControllerArguments($event);
$this->assertSame([$payload], $event->getArguments());
}
public function testMapRequestPayloadEmpty()
{
$payload = new RequestPayload(50);
$denormalizer = new RequestPayloadDenormalizer($payload);
$serializer = new Serializer([$denormalizer]);
$resolver = new RequestPayloadValueResolver($serializer);
$argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [
MapRequestPayload::class => new MapRequestPayload(mapWhenEmpty: true),
]);
$request = Request::create('/', 'POST');
$kernel = $this->createStub(HttpKernelInterface::class);
$arguments = $resolver->resolve($request, $argument);
$event = new ControllerArgumentsEvent($kernel, static fn () => null, $arguments, $request, HttpKernelInterface::MAIN_REQUEST);
$resolver->onKernelControllerArguments($event);
$this->assertSame([$payload], $event->getArguments());
}
public function testNullPayloadAndNotDefaultOrNullableArgument()
{
$validator = $this->createMock(ValidatorInterface::class);
@@ -1415,3 +1455,25 @@ class FormPayloadWithBool
{
}
}
class RequestPayloadDenormalizer implements DenormalizerInterface
{
public function __construct(private RequestPayload $payload)
{
}
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
return $this->payload;
}
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return RequestPayload::class === $type;
}
public function getSupportedTypes(?string $format = null): array
{
return [RequestPayload::class => true];
}
}