From 27476aeb448194136996f14f93364d5192d0c1c6 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Fri, 10 Apr 2026 19:07:59 +0200 Subject: [PATCH] Add support for #[MapRequestPayload] and #[MapQueryString] DTOs Co-Authored-By: Claude Code --- src/Provider/SymfonyUsageProvider.php | 93 +++++++++++++++++++++ tests/Provider/SymfonyUsageProviderTest.php | 8 +- tests/Rule/DeadCodeRuleTest.php | 1 + tests/Rule/data/providers/symfony.php | 49 +++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/src/Provider/SymfonyUsageProvider.php b/src/Provider/SymfonyUsageProvider.php index cfbb4a5e..9ec655c8 100644 --- a/src/Provider/SymfonyUsageProvider.php +++ b/src/Provider/SymfonyUsageProvider.php @@ -20,6 +20,7 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\TrinaryLogic; use PHPStan\Type\Constant\ConstantStringType; use RecursiveDirectoryIterator; @@ -29,6 +30,7 @@ use ShipMonk\PHPStan\DeadCode\Enum\AccessType; use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassConstantUsage; +use ShipMonk\PHPStan\DeadCode\Graph\ClassMemberUsage; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodRef; use ShipMonk\PHPStan\DeadCode\Graph\ClassMethodUsage; use ShipMonk\PHPStan\DeadCode\Graph\ClassPropertyRef; @@ -48,12 +50,15 @@ use function is_array; use function is_dir; use function is_string; +use function lcfirst; use function preg_match_all; use function reset; use function simplexml_load_string; use function sprintf; use function str_ends_with; use function str_starts_with; +use function strlen; +use function substr; use function trim; final class SymfonyUsageProvider implements MemberUsageProvider @@ -96,6 +101,7 @@ final class SymfonyUsageProvider implements MemberUsageProvider */ public function __construct( Container $container, + private readonly ReflectionProvider $reflectionProvider, ?bool $enabled, ?string $configDir, array $containerXmlPaths, @@ -148,6 +154,7 @@ public function getUsages( $usages = [ ...$usages, ...$this->getMethodUsagesFromAttributeReflection($node, $scope), + ...$this->getMapPayloadUsages($node), ]; } @@ -608,6 +615,92 @@ private function getMethodUsagesFromAttributeReflection( return $usages; } + /** + * @return list + */ + private function getMapPayloadUsages(InClassMethodNode $node): array + { + $usages = []; + + foreach ($node->getMethodReflection()->getParameters() as $parameter) { + $isMapPayload = false; + + foreach ($parameter->getAttributes() as $attributeReflection) { + if ( + $attributeReflection->getName() === 'Symfony\Component\HttpKernel\Attribute\MapRequestPayload' + || $attributeReflection->getName() === 'Symfony\Component\HttpKernel\Attribute\MapQueryString' + ) { + $isMapPayload = true; + break; + } + } + + if (!$isMapPayload) { + continue; + } + + $parameterType = $parameter->getType(); + + if (!$parameterType->isObject()->yes()) { + continue; + } + + foreach ($parameterType->getObjectClassNames() as $dtoClassName) { + if (!$this->reflectionProvider->hasClass($dtoClassName)) { + continue; + } + + $dtoReflection = $this->reflectionProvider->getClass($dtoClassName); + $origin = UsageOrigin::createVirtual($this, VirtualUsageData::withNote('DTO used via #[MapRequestPayload] or #[MapQueryString]')); + + // Mark constructor as used (serializer instantiates the DTO) + if ($dtoReflection->hasConstructor()) { + $usages[] = new ClassMethodUsage( + $origin, + new ClassMethodRef($dtoClassName, '__construct', possibleDescendant: false), + ); + } + + // Mark all declared properties as written (serializer populates them) + foreach ($dtoReflection->getNativeReflection()->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() !== $dtoClassName) { + continue; + } + + $usages[] = new ClassPropertyUsage( + $origin, + new ClassPropertyRef($dtoClassName, $property->getName(), possibleDescendant: false), + AccessType::WRITE, + ); + } + + // Mark setter methods as used (ObjectNormalizer calls them via PropertyAccessor) + foreach ($dtoReflection->getNativeReflection()->getMethods() as $dtoMethod) { + if ($dtoMethod->getDeclaringClass()->getName() !== $dtoClassName) { + continue; + } + + $dtoMethodName = $dtoMethod->getName(); + + if (!str_starts_with($dtoMethodName, 'set') || strlen($dtoMethodName) <= 3) { + continue; + } + + $propertyName = lcfirst(substr($dtoMethodName, 3)); + + if ($dtoReflection->getNativeReflection()->hasProperty($propertyName)) { + $usages[] = new ClassMethodUsage( + $origin, + new ClassMethodRef($dtoClassName, $dtoMethodName, possibleDescendant: false), + ); + } + } + } + } + + return $usages; + } + private function shouldMarkAsUsed(ReflectionMethod $method): ?string { if ($this->isBundleConstructor($method)) { diff --git a/tests/Provider/SymfonyUsageProviderTest.php b/tests/Provider/SymfonyUsageProviderTest.php index 8fb6928c..c19fbb4b 100644 --- a/tests/Provider/SymfonyUsageProviderTest.php +++ b/tests/Provider/SymfonyUsageProviderTest.php @@ -16,7 +16,7 @@ public function testAutodetectConfigDir(): void $configDir = __DIR__ . '/../../config'; @mkdir($configDir); - $provider = new SymfonyUsageProvider(self::getContainer(), true, null, []); + $provider = new SymfonyUsageProvider(self::getContainer(), self::createReflectionProvider(), true, null, []); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $configDirPropertyReflection = $providerReflection->getProperty('configDir'); @@ -38,7 +38,7 @@ public function testExplicitContainerXmlPaths(): void { $containerXmlPath = __DIR__ . '/../Rule/data/providers/symfony/services.xml'; - $provider = new SymfonyUsageProvider(self::getContainer(), true, null, [$containerXmlPath]); + $provider = new SymfonyUsageProvider(self::getContainer(), self::createReflectionProvider(), true, null, [$containerXmlPath]); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $dicCallsReflection = $providerReflection->getProperty('dicCalls'); @@ -56,7 +56,7 @@ public function testExplicitContainerXmlPathsTakesPrecedenceOverContainer(): voi $containerXmlPath = __DIR__ . '/../Rule/data/providers/symfony/services.xml'; // Even though self::getContainer() has no symfony config, the explicit paths are used - $provider = new SymfonyUsageProvider(self::getContainer(), true, null, [$containerXmlPath]); + $provider = new SymfonyUsageProvider(self::getContainer(), self::createReflectionProvider(), true, null, [$containerXmlPath]); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $dicCallsReflection = $providerReflection->getProperty('dicCalls'); @@ -71,7 +71,7 @@ public function testEmptyContainerXmlPathsFallsBackToContainer(): void { // When containerXmlPaths is empty, it falls back to getContainerXmlPath(container) // self::getContainer() has no symfony parameter, so no DIC classes are loaded - $provider = new SymfonyUsageProvider(self::getContainer(), true, null, []); + $provider = new SymfonyUsageProvider(self::getContainer(), self::createReflectionProvider(), true, null, []); $providerReflection = new ReflectionClass(SymfonyUsageProvider::class); $dicCallsReflection = $providerReflection->getProperty('dicCalls'); diff --git a/tests/Rule/DeadCodeRuleTest.php b/tests/Rule/DeadCodeRuleTest.php index 61fd12b1..eb7ea7c5 100644 --- a/tests/Rule/DeadCodeRuleTest.php +++ b/tests/Rule/DeadCodeRuleTest.php @@ -1210,6 +1210,7 @@ private function getMemberUsageProviders(): array ), new SymfonyUsageProvider( $this->createContainerMockWithSymfonyConfig(), + self::createReflectionProvider(), $this->providersEnabled, __DIR__ . '/data/providers/symfony/', [], diff --git a/tests/Rule/data/providers/symfony.php b/tests/Rule/data/providers/symfony.php index 161d9249..35cc8b04 100644 --- a/tests/Rule/data/providers/symfony.php +++ b/tests/Rule/data/providers/symfony.php @@ -202,3 +202,52 @@ class RequiredPropertyService { public object $unused; // error: Property Symfony\RequiredPropertyService::$unused is never read // error: Property Symfony\RequiredPropertyService::$unused is never written } +class CreateUserDto { + public function __construct( + public readonly string $name, + public readonly string $email, + ) {} +} + +class MapPayloadController { + #[Route('/api/users', methods: ['POST'])] + public function create( + #[\Symfony\Component\HttpKernel\Attribute\MapRequestPayload] CreateUserDto $dto, + ): void { + echo $dto->name; + echo $dto->email; + } +} + +class QueryStringDto { + public function __construct( + public readonly int $page, + public readonly int $limit, // error: Property Symfony\QueryStringDto::$limit is never read + ) {} +} + +class QueryStringController { + #[Route('/api/items')] + public function list( + #[\Symfony\Component\HttpKernel\Attribute\MapQueryString] QueryStringDto $query, + ): void { + echo $query->page; + } +} + +class SetterBasedDto { + private string $name; // error: Property Symfony\SetterBasedDto::$name is never read + + public function setName(string $name): void + { + $this->name = $name; + } +} + +class SetterController { + #[Route('/api/setter')] + public function create( + #[\Symfony\Component\HttpKernel\Attribute\MapRequestPayload] SetterBasedDto $dto, + ): void {} +} +