From 44ef0cde61f1ad878c0f26895e9269c7a5209364 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 25 Apr 2026 16:00:02 +0000 Subject: [PATCH 1/8] Narrow `ReflectionClass::getConstant()` and `ReflectionClass::getConstants()` return types based on generic parameter - Add `ReflectionClassGetConstantsDynamicReturnTypeExtension` that narrows return types when the `T` template parameter of `ReflectionClass` is known - `getConstant('NAME')` returns the exact constant value type when the constant exists, `false` when it doesn't, or a union of all constant types | false for dynamic names - `getConstants()` returns a constant array shape with all class constant names and their value types - `getConstants($filter)` respects visibility filter parameter (IS_PUBLIC/IS_PROTECTED/IS_PRIVATE) - Enum cases are handled as `EnumCaseObjectType`, regular constants use their value types - Inherited constants and interface constants are included - Falls back to default `mixed`/`array` when the generic parameter is unknown Co-Authored-By: Claude Opus 4.6 --- ...GetConstantsDynamicReturnTypeExtension.php | 169 ++++++++++++++++ .../nsrt/reflection-class-get-constants.php | 188 ++++++++++++++++++ 2 files changed, 357 insertions(+) create mode 100644 src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php diff --git a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php new file mode 100644 index 00000000000..f4d0069ed8a --- /dev/null +++ b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php @@ -0,0 +1,169 @@ +getName() === 'getConstant' + || $methodReflection->getName() === 'getConstants'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type + { + $calledOnType = $scope->getType($methodCall->var); + $reflectionType = $calledOnType->getTemplateType(ReflectionClass::class, 'T'); + + if ((new ObjectWithoutClassType())->isSuperTypeOf($reflectionType)->no()) { + return null; + } + + $classReflections = $reflectionType->getObjectClassReflections(); + if (count($classReflections) === 0) { + return null; + } + + if ($methodReflection->getName() === 'getConstant') { + return $this->resolveGetConstant($methodCall, $scope, $classReflections); + } + + $filterType = count($methodCall->getArgs()) >= 1 + ? $scope->getType($methodCall->getArgs()[0]->value) + : null; + + return $this->resolveGetConstants($classReflections, $filterType); + } + + /** + * @param list $classReflections + */ + private function resolveGetConstant(MethodCall $methodCall, Scope $scope, array $classReflections): ?Type + { + if (count($methodCall->getArgs()) < 1) { + return null; + } + + $nameType = $scope->getType($methodCall->getArgs()[0]->value); + $constantNames = $nameType->getConstantStrings(); + + if (count($constantNames) > 0) { + $types = []; + foreach ($classReflections as $classReflection) { + foreach ($constantNames as $constantName) { + $name = $constantName->getValue(); + if ($classReflection->isEnum() && $classReflection->hasEnumCase($name)) { + $types[] = new EnumCaseObjectType($classReflection->getName(), $name); + } elseif ($classReflection->hasConstant($name)) { + $types[] = $classReflection->getConstant($name)->getValueType(); + } else { + $types[] = new ConstantBooleanType(false); + } + } + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::union(...$types); + } + + $allConstantTypes = []; + foreach ($classReflections as $classReflection) { + foreach ($this->getClassConstants($classReflection) as [$name, $valueType]) { + $allConstantTypes[] = $valueType; + } + } + + if (count($allConstantTypes) === 0) { + return new ConstantBooleanType(false); + } + + $allConstantTypes[] = new ConstantBooleanType(false); + + return TypeCombinator::union(...$allConstantTypes); + } + + /** + * @param list $classReflections + */ + private function resolveGetConstants(array $classReflections, ?Type $filterType): ?Type + { + $filter = null; + if ($filterType !== null) { + $filterScalars = $filterType->getConstantScalarValues(); + if (count($filterScalars) === 1 && is_int($filterScalars[0])) { + $filter = $filterScalars[0]; + } + } + + $types = []; + foreach ($classReflections as $classReflection) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($this->getClassConstants($classReflection, $filter) as [$name, $valueType]) { + $builder->setOffsetValueType(new ConstantStringType($name), $valueType); + } + $types[] = $builder->getArray(); + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::union(...$types); + } + + /** + * @return list + */ + private function getClassConstants(ClassReflection $classReflection, ?int $filter = null): array + { + $constants = []; + foreach ($classReflection->getNativeReflection()->getReflectionConstants() as $reflectionConstant) { + $constantName = $reflectionConstant->getName(); + + if ($filter !== null && ($reflectionConstant->getModifiers() & $filter) === 0) { + continue; + } + + if ($classReflection->isEnum() && $classReflection->hasEnumCase($constantName)) { + $constants[] = [$constantName, new EnumCaseObjectType($classReflection->getName(), $constantName)]; + continue; + } + + if (!$classReflection->hasConstant($constantName)) { + continue; + } + + $constants[] = [$constantName, $classReflection->getConstant($constantName)->getValueType()]; + } + + return $constants; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php new file mode 100644 index 00000000000..8eaaeb8598d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php @@ -0,0 +1,188 @@ += 8.1 + +namespace ReflectionClassGetConstants; + +use ReflectionClass; +use function PHPStan\Testing\assertType; + +class Foo +{ + public const A = 1; + public const B = 'hello'; + protected const C = 3.14; + private const D = true; +} + +class Bar +{ + public const X = 'x'; +} + +final class FinalClass +{ + public const ONE = 1; + public const TWO = 2; +} + +enum SimpleEnum +{ + case Hearts; + case Diamonds; +} + +enum BackedEnum: string +{ + case Active = 'active'; + case Inactive = 'inactive'; +} + +enum MixedEnum: int +{ + const SOME_CONST = 42; + case One = 1; + case Two = 2; +} + +interface HasConstants +{ + public const IFACE_CONST = 'iface'; +} + +class ParentClass +{ + public const PARENT_CONST = 'parent'; +} + +class ChildClass extends ParentClass +{ + public const CHILD_CONST = 'child'; +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantKnown(ReflectionClass $ref): void +{ + assertType('1', $ref->getConstant('A')); + assertType("'hello'", $ref->getConstant('B')); + assertType('3.14', $ref->getConstant('C')); + assertType('true', $ref->getConstant('D')); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantNonExistent(ReflectionClass $ref): void +{ + assertType('false', $ref->getConstant('nonExistent')); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantDynamic(ReflectionClass $ref, string $name): void +{ + assertType("1|3.14|'hello'|bool", $ref->getConstant($name)); +} + +function testGetConstantUnknownClass(ReflectionClass $ref): void +{ + assertType('mixed', $ref->getConstant('A')); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstants(ReflectionClass $ref): void +{ + assertType("array{A: 1, B: 'hello', C: 3.14, D: true}", $ref->getConstants()); +} + +function testGetConstantsUnknownClass(ReflectionClass $ref): void +{ + assertType('array', $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsFinalClass(ReflectionClass $ref): void +{ + assertType('array{ONE: 1, TWO: 2}', $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsSimple(ReflectionClass $ref): void +{ + assertType("array{X: 'x'}", $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsEnum(ReflectionClass $ref): void +{ + assertType('array{Hearts: ReflectionClassGetConstants\SimpleEnum::Hearts, Diamonds: ReflectionClassGetConstants\SimpleEnum::Diamonds}', $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsBackedEnum(ReflectionClass $ref): void +{ + assertType('array{Active: ReflectionClassGetConstants\BackedEnum::Active, Inactive: ReflectionClassGetConstants\BackedEnum::Inactive}', $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsEnumWithConst(ReflectionClass $ref): void +{ + assertType('array{SOME_CONST: 42, One: ReflectionClassGetConstants\MixedEnum::One, Two: ReflectionClassGetConstants\MixedEnum::Two}', $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantEnumCase(ReflectionClass $ref): void +{ + assertType('ReflectionClassGetConstants\SimpleEnum::Hearts', $ref->getConstant('Hearts')); + assertType('false', $ref->getConstant('nonExistent')); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsInterface(ReflectionClass $ref): void +{ + assertType("array{IFACE_CONST: 'iface'}", $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsInheritance(ReflectionClass $ref): void +{ + assertType("array{CHILD_CONST: 'child', PARENT_CONST: 'parent'}", $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantInherited(ReflectionClass $ref): void +{ + assertType("'parent'", $ref->getConstant('PARENT_CONST')); + assertType("'child'", $ref->getConstant('CHILD_CONST')); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsWithFilter(ReflectionClass $ref): void +{ + assertType("array{A: 1, B: 'hello'}", $ref->getConstants(\ReflectionClassConstant::IS_PUBLIC)); + assertType('array{C: 3.14}', $ref->getConstants(\ReflectionClassConstant::IS_PROTECTED)); + assertType('array{D: true}', $ref->getConstants(\ReflectionClassConstant::IS_PRIVATE)); +} From d7dfaa6cf57c0d122c6b5d59b752118353a5542c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 25 Apr 2026 16:43:28 +0000 Subject: [PATCH 2/8] Make getConstants() array keys optional when filter is non-constant When getConstants() receives a filter argument that isn't a single constant integer value, we can't determine which constants will be included in the result. Mark all keys as optional in this case instead of treating them as always-defined. Co-Authored-By: Claude Opus 4.6 --- ...lectionClassGetConstantsDynamicReturnTypeExtension.php | 5 ++++- .../Analyser/nsrt/reflection-class-get-constants.php | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php index f4d0069ed8a..ec62f788add 100644 --- a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php @@ -115,10 +115,13 @@ private function resolveGetConstant(MethodCall $methodCall, Scope $scope, array private function resolveGetConstants(array $classReflections, ?Type $filterType): ?Type { $filter = null; + $filterIsUncertain = false; if ($filterType !== null) { $filterScalars = $filterType->getConstantScalarValues(); if (count($filterScalars) === 1 && is_int($filterScalars[0])) { $filter = $filterScalars[0]; + } else { + $filterIsUncertain = true; } } @@ -126,7 +129,7 @@ private function resolveGetConstants(array $classReflections, ?Type $filterType) foreach ($classReflections as $classReflection) { $builder = ConstantArrayTypeBuilder::createEmpty(); foreach ($this->getClassConstants($classReflection, $filter) as [$name, $valueType]) { - $builder->setOffsetValueType(new ConstantStringType($name), $valueType); + $builder->setOffsetValueType(new ConstantStringType($name), $valueType, $filterIsUncertain); } $types[] = $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php index 8eaaeb8598d..a4d4233b086 100644 --- a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php +++ b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php @@ -186,3 +186,11 @@ function testGetConstantsWithFilter(ReflectionClass $ref): void assertType('array{C: 3.14}', $ref->getConstants(\ReflectionClassConstant::IS_PROTECTED)); assertType('array{D: true}', $ref->getConstants(\ReflectionClassConstant::IS_PRIVATE)); } + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsWithDynamicFilter(ReflectionClass $ref, int $filter): void +{ + assertType("array{A?: 1, B?: 'hello', C?: 3.14, D?: true}", $ref->getConstants($filter)); +} From 32710c4873a14b4cc50ec612a8327c8f4a05464e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 25 Apr 2026 17:13:44 +0000 Subject: [PATCH 3/8] Compute precise return types for getConstants() with multiple constant filter values Instead of making all array keys optional when the filter has multiple constant scalar values, compute the result for each filter value separately and return their union. Co-Authored-By: Claude Opus 4.6 --- ...GetConstantsDynamicReturnTypeExtension.php | 39 ++++++++++++++++++- .../nsrt/reflection-class-get-constants.php | 9 +++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php index ec62f788add..089fd6495ce 100644 --- a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php @@ -118,8 +118,19 @@ private function resolveGetConstants(array $classReflections, ?Type $filterType) $filterIsUncertain = false; if ($filterType !== null) { $filterScalars = $filterType->getConstantScalarValues(); - if (count($filterScalars) === 1 && is_int($filterScalars[0])) { - $filter = $filterScalars[0]; + $intFilters = []; + foreach ($filterScalars as $scalar) { + if (!is_int($scalar)) { + $intFilters = null; + break; + } + $intFilters[] = $scalar; + } + + if ($intFilters !== null && count($intFilters) === 1) { + $filter = $intFilters[0]; + } elseif ($intFilters !== null && count($intFilters) > 1) { + return $this->resolveGetConstantsForMultipleFilters($classReflections, $intFilters); } else { $filterIsUncertain = true; } @@ -141,6 +152,30 @@ private function resolveGetConstants(array $classReflections, ?Type $filterType) return TypeCombinator::union(...$types); } + /** + * @param list $classReflections + * @param list $filters + */ + private function resolveGetConstantsForMultipleFilters(array $classReflections, array $filters): ?Type + { + $types = []; + foreach ($filters as $filter) { + foreach ($classReflections as $classReflection) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($this->getClassConstants($classReflection, $filter) as [$name, $valueType]) { + $builder->setOffsetValueType(new ConstantStringType($name), $valueType); + } + $types[] = $builder->getArray(); + } + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::union(...$types); + } + /** * @return list */ diff --git a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php index a4d4233b086..c5187b89fca 100644 --- a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php +++ b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php @@ -194,3 +194,12 @@ function testGetConstantsWithDynamicFilter(ReflectionClass $ref, int $filter): v { assertType("array{A?: 1, B?: 'hello', C?: 3.14, D?: true}", $ref->getConstants($filter)); } + +/** + * @param ReflectionClass $ref + * @param \ReflectionClassConstant::IS_PUBLIC|\ReflectionClassConstant::IS_PROTECTED $filter + */ +function testGetConstantsWithMultipleConstantFilters(ReflectionClass $ref, int $filter): void +{ + assertType("array{A: 1, B: 'hello'}|array{C: 3.14}", $ref->getConstants($filter)); +} From 6f0afd85eeadcfcaa525266acf6c3644ced9fc1a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 25 Apr 2026 17:25:57 +0000 Subject: [PATCH 4/8] Simplify extension to use synthetic ClassConstFetch via Scope::getType() Replace manual constant type resolution (enum case checks, getValueType()) with synthetic ClassConstFetch nodes passed to Scope::getType(). This reuses the existing type resolution in InitializerExprTypeResolver which already handles enum cases, final/non-final constants, typed constants, and circular references. Co-Authored-By: Claude Opus 4.6 --- ...GetConstantsDynamicReturnTypeExtension.php | 124 +++++++++--------- 1 file changed, 65 insertions(+), 59 deletions(-) diff --git a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php index 089fd6495ce..916a44b58e3 100644 --- a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php @@ -2,7 +2,10 @@ namespace PHPStan\Type\Php; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\Identifier; +use PhpParser\Node\Name\FullyQualified; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\ClassReflection; @@ -11,7 +14,6 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\Enum\EnumCaseObjectType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -56,7 +58,16 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method ? $scope->getType($methodCall->getArgs()[0]->value) : null; - return $this->resolveGetConstants($classReflections, $filterType); + return $this->resolveGetConstants($scope, $classReflections, $filterType); + } + + /** @param non-empty-string $name */ + private function getConstantType(Scope $scope, ClassReflection $classReflection, string $name): Type + { + return $scope->getType(new ClassConstFetch( + new FullyQualified($classReflection->getName()), + new Identifier($name), + )); } /** @@ -76,10 +87,11 @@ private function resolveGetConstant(MethodCall $methodCall, Scope $scope, array foreach ($classReflections as $classReflection) { foreach ($constantNames as $constantName) { $name = $constantName->getValue(); - if ($classReflection->isEnum() && $classReflection->hasEnumCase($name)) { - $types[] = new EnumCaseObjectType($classReflection->getName(), $name); - } elseif ($classReflection->hasConstant($name)) { - $types[] = $classReflection->getConstant($name)->getValueType(); + if ($name === '') { + continue; + } + if ($classReflection->hasConstant($name)) { + $types[] = $this->getConstantType($scope, $classReflection, $name); } else { $types[] = new ConstantBooleanType(false); } @@ -95,8 +107,8 @@ private function resolveGetConstant(MethodCall $methodCall, Scope $scope, array $allConstantTypes = []; foreach ($classReflections as $classReflection) { - foreach ($this->getClassConstants($classReflection) as [$name, $valueType]) { - $allConstantTypes[] = $valueType; + foreach ($this->getConstantNames($classReflection) as $name) { + $allConstantTypes[] = $this->getConstantType($scope, $classReflection, $name); } } @@ -112,61 +124,61 @@ private function resolveGetConstant(MethodCall $methodCall, Scope $scope, array /** * @param list $classReflections */ - private function resolveGetConstants(array $classReflections, ?Type $filterType): ?Type + private function resolveGetConstants(Scope $scope, array $classReflections, ?Type $filterType): ?Type { - $filter = null; - $filterIsUncertain = false; - if ($filterType !== null) { - $filterScalars = $filterType->getConstantScalarValues(); - $intFilters = []; - foreach ($filterScalars as $scalar) { - if (!is_int($scalar)) { - $intFilters = null; - break; - } - $intFilters[] = $scalar; - } + if ($filterType === null) { + return $this->buildConstantsArray($scope, $classReflections, null, false); + } - if ($intFilters !== null && count($intFilters) === 1) { - $filter = $intFilters[0]; - } elseif ($intFilters !== null && count($intFilters) > 1) { - return $this->resolveGetConstantsForMultipleFilters($classReflections, $intFilters); - } else { - $filterIsUncertain = true; + $filterScalars = $filterType->getConstantScalarValues(); + $intFilters = []; + foreach ($filterScalars as $scalar) { + if (!is_int($scalar)) { + $intFilters = null; + break; } + $intFilters[] = $scalar; } - $types = []; - foreach ($classReflections as $classReflection) { - $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($this->getClassConstants($classReflection, $filter) as [$name, $valueType]) { - $builder->setOffsetValueType(new ConstantStringType($name), $valueType, $filterIsUncertain); - } - $types[] = $builder->getArray(); + if ($intFilters !== null && count($intFilters) === 1) { + return $this->buildConstantsArray($scope, $classReflections, $intFilters[0], false); } - if (count($types) === 0) { - return null; + if ($intFilters !== null && count($intFilters) > 1) { + $types = []; + foreach ($intFilters as $filter) { + $result = $this->buildConstantsArray($scope, $classReflections, $filter, false); + if ($result !== null) { + $types[] = $result; + } + } + + if (count($types) === 0) { + return null; + } + + return TypeCombinator::union(...$types); } - return TypeCombinator::union(...$types); + return $this->buildConstantsArray($scope, $classReflections, null, true); } /** * @param list $classReflections - * @param list $filters */ - private function resolveGetConstantsForMultipleFilters(array $classReflections, array $filters): ?Type + private function buildConstantsArray(Scope $scope, array $classReflections, ?int $filter, bool $optional): ?Type { $types = []; - foreach ($filters as $filter) { - foreach ($classReflections as $classReflection) { - $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($this->getClassConstants($classReflection, $filter) as [$name, $valueType]) { - $builder->setOffsetValueType(new ConstantStringType($name), $valueType); - } - $types[] = $builder->getArray(); + foreach ($classReflections as $classReflection) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($this->getConstantNames($classReflection, $filter) as $name) { + $builder->setOffsetValueType( + new ConstantStringType($name), + $this->getConstantType($scope, $classReflection, $name), + $optional, + ); } + $types[] = $builder->getArray(); } if (count($types) === 0) { @@ -177,31 +189,25 @@ private function resolveGetConstantsForMultipleFilters(array $classReflections, } /** - * @return list + * @return list */ - private function getClassConstants(ClassReflection $classReflection, ?int $filter = null): array + private function getConstantNames(ClassReflection $classReflection, ?int $filter = null): array { - $constants = []; + $names = []; foreach ($classReflection->getNativeReflection()->getReflectionConstants() as $reflectionConstant) { - $constantName = $reflectionConstant->getName(); - if ($filter !== null && ($reflectionConstant->getModifiers() & $filter) === 0) { continue; } - if ($classReflection->isEnum() && $classReflection->hasEnumCase($constantName)) { - $constants[] = [$constantName, new EnumCaseObjectType($classReflection->getName(), $constantName)]; - continue; - } - - if (!$classReflection->hasConstant($constantName)) { + $name = $reflectionConstant->getName(); + if ($name === '') { continue; } - $constants[] = [$constantName, $classReflection->getConstant($constantName)->getValueType()]; + $names[] = $name; } - return $constants; + return $names; } } From 3ffb67d6ae49d11882b4af43f01257450230376b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 25 Apr 2026 18:03:46 +0000 Subject: [PATCH 5/8] Fall back to default return type for ReflectionClass with non-final classes When the generic parameter T has covariant variance and the reflected class is not final (or an enum), subclasses may add or override constants, so we cannot make precise assertions about constant types or shapes. The extension now detects this via ClassReflection::getCallSiteVarianceMap() and falls back to the default return type (mixed for getConstant, array for getConstants). Final classes and enums remain precise even with covariant variance since they cannot be subclassed. Co-Authored-By: Claude Opus 4.6 --- ...GetConstantsDynamicReturnTypeExtension.php | 32 ++++++++++++++ .../nsrt/reflection-class-get-constants.php | 44 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php index 916a44b58e3..2f24c45e55c 100644 --- a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php @@ -50,6 +50,10 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return null; } + if ($this->isCovariantWithNonFinalClass($calledOnType, $classReflections)) { + return null; + } + if ($methodReflection->getName() === 'getConstant') { return $this->resolveGetConstant($methodCall, $scope, $classReflections); } @@ -210,4 +214,32 @@ private function getConstantNames(ClassReflection $classReflection, ?int $filter return $names; } + /** @param list $classReflections */ + private function isCovariantWithNonFinalClass(Type $calledOnType, array $classReflections): bool + { + $hasNonFinalClass = false; + foreach ($classReflections as $classReflection) { + if (!$classReflection->isFinal() && !$classReflection->isEnum()) { + $hasNonFinalClass = true; + break; + } + } + + if (!$hasNonFinalClass) { + return false; + } + + foreach ($calledOnType->getObjectClassReflections() as $reflectionClassReflection) { + if ($reflectionClassReflection->getName() !== 'ReflectionClass') { + continue; + } + $variance = $reflectionClassReflection->getCallSiteVarianceMap()->getVariance('T'); + if ($variance !== null && $variance->covariant()) { + return true; + } + } + + return false; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php index c5187b89fca..d028477b174 100644 --- a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php +++ b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php @@ -203,3 +203,47 @@ function testGetConstantsWithMultipleConstantFilters(ReflectionClass $ref, int $ { assertType("array{A: 1, B: 'hello'}|array{C: 3.14}", $ref->getConstants($filter)); } + +/** + * @param ReflectionClass $ref + */ +function testGetConstantCovariant(ReflectionClass $ref): void +{ + assertType('mixed', $ref->getConstant('A')); + assertType('mixed', $ref->getConstant('nonExistent')); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantCovariantDynamic(ReflectionClass $ref, string $name): void +{ + assertType('mixed', $ref->getConstant($name)); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantsCovariant(ReflectionClass $ref): void +{ + assertType('array', $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantCovariantFinalClass(ReflectionClass $ref): void +{ + assertType('1', $ref->getConstant('ONE')); + assertType('false', $ref->getConstant('nonExistent')); + assertType('array{ONE: 1, TWO: 2}', $ref->getConstants()); +} + +/** + * @param ReflectionClass $ref + */ +function testGetConstantCovariantEnum(ReflectionClass $ref): void +{ + assertType('ReflectionClassGetConstants\SimpleEnum::Hearts', $ref->getConstant('Hearts')); + assertType('array{Hearts: ReflectionClassGetConstants\SimpleEnum::Hearts, Diamonds: ReflectionClassGetConstants\SimpleEnum::Diamonds}', $ref->getConstants()); +} From 44731572c82a74327f2b306852e96a66192099ce Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 25 Apr 2026 18:32:50 +0000 Subject: [PATCH 6/8] Add tests for direct instantiation of ReflectionClass with non-final class Demonstrates that `new ReflectionClass(NonFinalClass::class)` returns precise constant types since the generic parameter T is invariant, not covariant. Co-Authored-By: Claude Opus 4.6 --- .../nsrt/reflection-class-get-constants.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php index d028477b174..def17e05eb2 100644 --- a/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php +++ b/tests/PHPStan/Analyser/nsrt/reflection-class-get-constants.php @@ -247,3 +247,19 @@ function testGetConstantCovariantEnum(ReflectionClass $ref): void assertType('ReflectionClassGetConstants\SimpleEnum::Hearts', $ref->getConstant('Hearts')); assertType('array{Hearts: ReflectionClassGetConstants\SimpleEnum::Hearts, Diamonds: ReflectionClassGetConstants\SimpleEnum::Diamonds}', $ref->getConstants()); } + +function testGetConstantDirectInstantiation(): void +{ + $ref = new ReflectionClass(Foo::class); + assertType('1', $ref->getConstant('A')); + assertType("'hello'", $ref->getConstant('B')); + assertType('false', $ref->getConstant('nonExistent')); + assertType("array{A: 1, B: 'hello', C: 3.14, D: true}", $ref->getConstants()); +} + +function testGetConstantDirectInstantiationFinalClass(): void +{ + $ref = new ReflectionClass(FinalClass::class); + assertType('1', $ref->getConstant('ONE')); + assertType('array{ONE: 1, TWO: 2}', $ref->getConstants()); +} From 18e223af4c8c377efa9a1ddc58a4750c72bb205e Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 25 Apr 2026 20:56:51 +0200 Subject: [PATCH 7/8] Simplify --- ...GetConstantsDynamicReturnTypeExtension.php | 162 ++++++++---------- 1 file changed, 68 insertions(+), 94 deletions(-) diff --git a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php index 2f24c45e55c..fb69bd2ecf2 100644 --- a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php @@ -14,12 +14,10 @@ use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use ReflectionClass; use function count; -use function is_int; #[AutowiredService] final class ReflectionClassGetConstantsDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension @@ -41,16 +39,12 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method $calledOnType = $scope->getType($methodCall->var); $reflectionType = $calledOnType->getTemplateType(ReflectionClass::class, 'T'); - if ((new ObjectWithoutClassType())->isSuperTypeOf($reflectionType)->no()) { - return null; - } - $classReflections = $reflectionType->getObjectClassReflections(); if (count($classReflections) === 0) { return null; } - if ($this->isCovariantWithNonFinalClass($calledOnType, $classReflections)) { + if (!$this->isInvariantOrFinalClass($calledOnType, $classReflections)) { return null; } @@ -65,17 +59,38 @@ public function getTypeFromMethodCall(MethodReflection $methodReflection, Method return $this->resolveGetConstants($scope, $classReflections, $filterType); } - /** @param non-empty-string $name */ - private function getConstantType(Scope $scope, ClassReflection $classReflection, string $name): Type + /** + * @param non-empty-list $classReflections + */ + private function isInvariantOrFinalClass(Type $calledOnType, array $classReflections): bool { - return $scope->getType(new ClassConstFetch( - new FullyQualified($classReflection->getName()), - new Identifier($name), - )); + $hasNonFinalClass = false; + foreach ($classReflections as $classReflection) { + if (!$classReflection->isFinal() && !$classReflection->isEnum()) { + $hasNonFinalClass = true; + break; + } + } + + if (!$hasNonFinalClass) { + return true; + } + + foreach ($calledOnType->getObjectClassReflections() as $reflectionClassReflection) { + if ($reflectionClassReflection->getName() !== 'ReflectionClass') { + return false; + } + $variance = $reflectionClassReflection->getCallSiteVarianceMap()->getVariance('T'); + if ($variance !== null && $variance->covariant()) { + return false; + } + } + + return true; } /** - * @param list $classReflections + * @param non-empty-list $classReflections */ private function resolveGetConstant(MethodCall $methodCall, Scope $scope, array $classReflections): ?Type { @@ -126,70 +141,14 @@ private function resolveGetConstant(MethodCall $methodCall, Scope $scope, array } /** - * @param list $classReflections + * @param non-empty-string $name */ - private function resolveGetConstants(Scope $scope, array $classReflections, ?Type $filterType): ?Type - { - if ($filterType === null) { - return $this->buildConstantsArray($scope, $classReflections, null, false); - } - - $filterScalars = $filterType->getConstantScalarValues(); - $intFilters = []; - foreach ($filterScalars as $scalar) { - if (!is_int($scalar)) { - $intFilters = null; - break; - } - $intFilters[] = $scalar; - } - - if ($intFilters !== null && count($intFilters) === 1) { - return $this->buildConstantsArray($scope, $classReflections, $intFilters[0], false); - } - - if ($intFilters !== null && count($intFilters) > 1) { - $types = []; - foreach ($intFilters as $filter) { - $result = $this->buildConstantsArray($scope, $classReflections, $filter, false); - if ($result !== null) { - $types[] = $result; - } - } - - if (count($types) === 0) { - return null; - } - - return TypeCombinator::union(...$types); - } - - return $this->buildConstantsArray($scope, $classReflections, null, true); - } - - /** - * @param list $classReflections - */ - private function buildConstantsArray(Scope $scope, array $classReflections, ?int $filter, bool $optional): ?Type + private function getConstantType(Scope $scope, ClassReflection $classReflection, string $name): Type { - $types = []; - foreach ($classReflections as $classReflection) { - $builder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($this->getConstantNames($classReflection, $filter) as $name) { - $builder->setOffsetValueType( - new ConstantStringType($name), - $this->getConstantType($scope, $classReflection, $name), - $optional, - ); - } - $types[] = $builder->getArray(); - } - - if (count($types) === 0) { - return null; - } - - return TypeCombinator::union(...$types); + return $scope->getType(new ClassConstFetch( + new FullyQualified($classReflection->getName()), + new Identifier($name), + )); } /** @@ -214,32 +173,47 @@ private function getConstantNames(ClassReflection $classReflection, ?int $filter return $names; } - /** @param list $classReflections */ - private function isCovariantWithNonFinalClass(Type $calledOnType, array $classReflections): bool + /** + * @param non-empty-list $classReflections + */ + private function resolveGetConstants(Scope $scope, array $classReflections, ?Type $filterType): ?Type { - $hasNonFinalClass = false; - foreach ($classReflections as $classReflection) { - if (!$classReflection->isFinal() && !$classReflection->isEnum()) { - $hasNonFinalClass = true; - break; - } + if ($filterType === null) { + return $this->buildConstantsArray($scope, $classReflections, null, false); } - if (!$hasNonFinalClass) { - return false; + $filterScalars = $filterType->getConstantScalarValues(); + if (count($filterScalars) === 0) { + return $this->buildConstantsArray($scope, $classReflections, null, true); } - foreach ($calledOnType->getObjectClassReflections() as $reflectionClassReflection) { - if ($reflectionClassReflection->getName() !== 'ReflectionClass') { - continue; - } - $variance = $reflectionClassReflection->getCallSiteVarianceMap()->getVariance('T'); - if ($variance !== null && $variance->covariant()) { - return true; + $types = []; + foreach ($filterScalars as $filter) { + $types[] = $this->buildConstantsArray($scope, $classReflections, (int) $filter, false); + } + + return TypeCombinator::union(...$types); + } + + /** + * @param non-empty-list $classReflections + */ + private function buildConstantsArray(Scope $scope, array $classReflections, ?int $filter, bool $optional): Type + { + $types = []; + foreach ($classReflections as $classReflection) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($this->getConstantNames($classReflection, $filter) as $name) { + $builder->setOffsetValueType( + new ConstantStringType($name), + $this->getConstantType($scope, $classReflection, $name), + $optional, + ); } + $types[] = $builder->getArray(); } - return false; + return TypeCombinator::union(...$types); } } From b4ebae85c8162c940f9c0fb7d6e009ab7c2303d3 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 25 Apr 2026 21:03:02 +0200 Subject: [PATCH 8/8] Fix --- .../ReflectionClassGetConstantsDynamicReturnTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php index fb69bd2ecf2..c6d1d7321ed 100644 --- a/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php +++ b/src/Type/Php/ReflectionClassGetConstantsDynamicReturnTypeExtension.php @@ -176,7 +176,7 @@ private function getConstantNames(ClassReflection $classReflection, ?int $filter /** * @param non-empty-list $classReflections */ - private function resolveGetConstants(Scope $scope, array $classReflections, ?Type $filterType): ?Type + private function resolveGetConstants(Scope $scope, array $classReflections, ?Type $filterType): Type { if ($filterType === null) { return $this->buildConstantsArray($scope, $classReflections, null, false);