Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions src/Provider/SymfonyUsageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -96,6 +101,7 @@ final class SymfonyUsageProvider implements MemberUsageProvider
*/
public function __construct(
Container $container,
private readonly ReflectionProvider $reflectionProvider,
?bool $enabled,
?string $configDir,
array $containerXmlPaths,
Expand Down Expand Up @@ -148,6 +154,7 @@ public function getUsages(
$usages = [
...$usages,
...$this->getMethodUsagesFromAttributeReflection($node, $scope),
...$this->getMapPayloadUsages($node),
];
}

Expand Down Expand Up @@ -608,6 +615,92 @@ private function getMethodUsagesFromAttributeReflection(
return $usages;
}

/**
* @return list<ClassMemberUsage>
*/
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)) {
Expand Down
8 changes: 4 additions & 4 deletions tests/Provider/SymfonyUsageProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions tests/Rule/DeadCodeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,7 @@ private function getMemberUsageProviders(): array
),
new SymfonyUsageProvider(
$this->createContainerMockWithSymfonyConfig(),
self::createReflectionProvider(),
$this->providersEnabled,
__DIR__ . '/data/providers/symfony/',
[],
Expand Down
49 changes: 49 additions & 0 deletions tests/Rule/data/providers/symfony.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}

Loading