Skip to content

Symfony: Add support for #[MapRequestPayload] and #[MapQueryString] DTOs - #339

Merged
janedbal merged 13 commits into
masterfrom
feature/symfony-map-request-payload
Jul 15, 2026
Merged

Symfony: Add support for #[MapRequestPayload] and #[MapQueryString] DTOs#339
janedbal merged 13 commits into
masterfrom
feature/symfony-map-request-payload

Conversation

@janedbal

@janedbal janedbal commented Apr 10, 2026

Copy link
Copy Markdown
Member

Problem

When a Symfony controller parameter uses #[MapRequestPayload] or #[MapQueryString], the framework's RequestPayloadValueResolver uses the serializer to create the DTO object. The DTO's constructor is called and its properties are populated — all invisible in user source code. This causes false positives where the DTO constructor, properties, and setter methods are reported as dead code.

The serializer behavior depends on the normalizer used. With the default ObjectNormalizer (registered at priority -1000 in Symfony's framework-bundle):

  • Constructor is called with matching parameters
  • Mutator methods (setFoo(), addFoo()/removeFoo() pairs) are called via PropertyAccessor
  • Public non-readonly properties are written directly if no mutator exists

Changes

  • Detect #[MapRequestPayload] and #[MapQueryString] attributes (including their subclasses, matching the vendor's IS_INSTANCEOF lookup) on controller method parameters
  • Resolve the parameter type (nullable supported) to the DTO class; #[MapRequestPayload(type: X::class)] array payloads (attribute argument available since symfony/http-kernel 7.1) resolve to X
  • Mark the DTO constructor as used
  • Mark writable (public, non-static, non-readonly) properties as written, including inherited ones
  • Mark mutators the way ReflectionExtractor::getWriteInfo() resolves them: public set* methods callable with one argument (no backing property required) and add*/remove* pairs
  • Recurse into nested DTOs reachable via constructor parameters, writable properties and mutator arguments, including PhpDoc-typed collections (e.g. list<ItemDto>), matching the vendor's PropertyInfo-based type resolution

Known limitations

  • The implementation models the default ObjectNormalizer population strategy; custom denormalizers or PropertyNormalizer (which writes private properties via reflection) and #[DiscriminatorMap] subclass instantiation may still produce false positives

Co-Authored-By: Claude Code

@janedbal janedbal changed the title Add support for #[MapRequestPayload] and #[MapQueryString] DTOs Symfony: Add support for #[MapRequestPayload] and #[MapQueryString] DTOs Apr 11, 2026
@ruudk

ruudk commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@janedbal Can this be merged?

janedbal added 9 commits July 13, 2026 22:30
…meters

The vendor RequestPayloadValueResolver explicitly supports nullable and
defaulted controller arguments (it resolves to null when the query string
or payload is absent), so `#[MapQueryString] ?FilterDto $filter = null`
is the canonical usage pattern.

The provider guarded the parameter type with isObject()->yes(), which is
only "maybe" for a nullable type, so such DTOs were skipped entirely and
their constructor/properties reported as dead. Strip null from the type
before extracting object class names instead.

Co-Authored-By: Claude Code
The vendor RequestPayloadValueResolver matches these attributes with
ArgumentMetadata::IS_INSTANCEOF, so custom attributes extending
MapRequestPayload or MapQueryString (e.g. to preset a format or
validation groups) are resolved the same way. The provider compared
attribute class names strictly, missing such subclasses and reporting
their DTOs as dead. Resolve the attribute class and check descendance
instead.

Co-Authored-By: Claude Code
The vendor resolver supports mapping a JSON array into a list of DTOs:
an `array`-typed controller argument combined with the attribute's $type
argument is denormalized to `X[]`, instantiating X for every item. The
provider only inspected the parameter type (which yields no object class
for `array`), so DTOs used exclusively this way were reported dead. Read
the attribute's `type` argument as an additional DTO class source.

Co-Authored-By: Claude Code
PropertyAccessor (via ReflectionExtractor::getWriteInfo) resolves a
mutator purely by name and accessibility: a public set* method accepting
one argument is invoked for a matching payload key, whether or not a
backing property with the same name exists (e.g. a setter storing into
an internal array). The provider required a same-named property to
exist, leaving such setters reported as dead.

Drop the property-existence requirement and instead mirror the vendor's
isMethodAccessible() criteria: the setter must be public and callable
with exactly one argument. This also stops marking zero-argument or
non-public set* methods, which the vendor never calls.

Co-Authored-By: Claude Code
For pluralizable payload keys, ReflectionExtractor::getWriteInfo()
resolves an adder/remover pair (addTag()/removeTag()) before falling
back to a setter, so collection-style DTOs are populated through those
methods at runtime. The provider only recognized set* mutators, leaving
adders and removers reported as dead.

Matching the vendor, a lone adder without its remover counterpart is
not used (the pair is required), and both methods must satisfy the
same accessibility rules as setters.

Co-Authored-By: Claude Code
PropertyAccessor reflects on the concrete DTO class, so public
properties and mutators inherited from a parent class are populated the
same way as own ones. The provider skipped members not declared
directly on the DTO, which left members of project-level parent classes
(not covered by the vendor usage provider) reported as dead when the
parent was never itself used as a controller argument.

Anchor the emitted usages to the declaring class so they resolve to the
parent's members.

Co-Authored-By: Claude Code
The provider marked every declared property of a payload DTO as
written, but PropertyAccessor can only write public non-static
non-readonly properties directly. Readonly and promoted properties are
populated through the constructor and private ones only through
mutators - both paths already produce visible or provider-emitted
usages of their own.

Over-marking suppressed real dead code: a private property with no
setter and no constructor assignment is never populated by the
serializer, yet its "never written" report was silenced.

Co-Authored-By: Claude Code
The serializer denormalizes object-typed constructor parameters,
writable properties and mutator arguments recursively, instantiating
and populating nested DTOs exactly like the root one. The provider only
processed the class of the controller argument itself, so members of
nested DTOs were reported as dead.

Collect usages recursively over natively-typed constructor parameters,
writable properties and mutator parameters, guarding against type
cycles with a visited set (same approach as #[MapInput] handling).
Collections of nested DTOs declared only via PhpDoc (e.g. list<ItemDto>)
are not traversed, as native reflection carries no element type.

Co-Authored-By: Claude Code
@janedbal
janedbal force-pushed the feature/symfony-map-request-payload branch from 27476ae to cb9061c Compare July 13, 2026 20:51
janedbal added 3 commits July 13, 2026 22:55
The payload mapping feature grew enough test classes to warrant a
dedicated fixture file, gated on symfony/http-kernel >= 6.3 where these
attributes were introduced (the shared symfony.php fixture has no
version guard).

Co-Authored-By: Claude Code
The $type argument of #[MapRequestPayload] only exists since
symfony/http-kernel 7.1. On older versions the attribute has no such
constructor parameter, so no usage is emitted (correctly matching the
runtime, where array payload mapping does not exist) and the shared
fixture failed on PHP 8.1 (http-kernel downgraded to 6.4) and
prefer-lowest jobs. Move the test case into a version-gated fixture.

Co-Authored-By: Claude Code
testNoFatalError require_onces every fixture regardless of version
gates, and the map-payload fixture declares an attribute extending
MapRequestPayload, which is a load-time fatal when the parent class
does not exist (prefer-lowest installs http-kernel 5.4). Skip the
declarations via an early return when the class is missing; the
corresponding testDead data set is already version-skipped there.

Co-Authored-By: Claude Code
@janedbal
janedbal marked this pull request as ready for review July 14, 2026 07:56
The vendor's AbstractObjectNormalizer resolves attribute types through
PropertyInfo, whose PhpDocExtractor reads @var/@param annotations - so
an array constructor parameter or property annotated list<ItemDto> is
denormalized element-by-element, instantiating and populating ItemDto.
The nested-DTO recursion only followed native reflection types and
stopped at `array`, leaving such element DTOs reported as dead.

Use PHPStan's reflection (which merges PhpDoc and native types) for
constructor parameters, writable properties and mutator arguments, and
unwrap iterable value types recursively.

Co-Authored-By: Claude Code
@janedbal
janedbal merged commit 2c376b3 into master Jul 15, 2026
33 checks passed
@janedbal
janedbal deleted the feature/symfony-map-request-payload branch July 15, 2026 12:28
@janedbal

janedbal commented Jul 17, 2026

Copy link
Copy Markdown
Member Author

@ruudk Yes, released in 1.3.2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants