From 7b15f0bcfeec0096f2afc8c2bf8be65d1a90e4da Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 18 Jun 2026 07:11:16 +0000 Subject: [PATCH 1/9] Infer `non-falsy-string` for the magic `name` property on the `UnitEnum`/`BackedEnum` interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In `PhpClassReflectionExtension::createProperty()`, set the PHPDoc type of the magic `name` property to `non-falsy-string` when it is declared on the `UnitEnum` interface (also inherited by `BackedEnum` and any `T of UnitEnum` template bound). Previously BetterReflection synthesized it as plain `string`. - Enum case names are always valid PHP labels, so they can never be empty or "0". - Concrete enum types (`Foo $foo`) and individual enum case objects (`Foo::A->name`) were already correct — they resolve to a union of constant strings / a single constant string, which are non-falsy. The backed-enum `value` property is intentionally left as-is, since it may legitimately be an empty string or "0". --- .../Php/PhpClassReflectionExtension.php | 11 +++++ tests/PHPStan/Analyser/nsrt/bug-14839.php | 41 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14839.php diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 8d856b59a7d..e1b94e20e28 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -38,6 +38,7 @@ use PHPStan\Reflection\SignatureMap\SignatureMapProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantStringType; @@ -51,6 +52,7 @@ use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; +use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypehintHelper; @@ -356,6 +358,15 @@ private function createProperty( ); } + if ( + $phpDocType === null + && $propertyName === 'name' + && $declaringClassName === 'UnitEnum' + ) { + // enum case names are valid PHP labels, so they can never be empty or "0" + $phpDocType = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType()); + } + $nativeType = TypehintHelper::decideTypeFromReflection($propertyReflection->getType(), selfClass: $declaringClassReflection); $declaringTrait = null; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14839.php b/tests/PHPStan/Analyser/nsrt/bug-14839.php new file mode 100644 index 00000000000..ae32a701e1c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14839.php @@ -0,0 +1,41 @@ += 8.1 + +declare(strict_types=1); + +namespace Bug14839; + +use function PHPStan\Testing\assertType; + +enum Foo +{ + + case A; + case B; + +} + +enum Bar: string +{ + + case A = 'a'; + case B = 'b'; + +} + +function test(Foo $foo, Bar $bar, \UnitEnum $u, \BackedEnum $b): void +{ + assertType("'A'|'B'", $foo->name); + assertType("'A'|'B'", $bar->name); + assertType('non-falsy-string', $u->name); + assertType('non-falsy-string', $b->name); + assertType('int|string', $b->value); +} + +/** + * @template T of \UnitEnum + * @param T $enum + */ +function testTemplate($enum): void +{ + assertType('non-falsy-string', $enum->name); +} From 8abcc427a1bc97db062181b3c3d531c00957b334 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 09:42:12 +0200 Subject: [PATCH 2/9] Update PhpClassReflectionExtension.php --- src/Reflection/Php/PhpClassReflectionExtension.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index e1b94e20e28..df9ad0cffcf 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -15,6 +15,7 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionParameter; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty; use PHPStan\Parser\Parser; +use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\PhpDocInheritanceResolver; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDoc\StubPhpDocProvider; @@ -105,6 +106,7 @@ public function __construct( private AttributeReflectionFactory $attributeReflectionFactory, private ParameterAllowedConstantsMapProvider $allowedConstantsMapProvider, private bool $inferPrivatePropertyTypeFromConstructor, + private PhpVersion $phpVersion, ) { } @@ -360,6 +362,7 @@ private function createProperty( if ( $phpDocType === null + && $this->phpVersion->supportsEnums() && $propertyName === 'name' && $declaringClassName === 'UnitEnum' ) { From d395f82202d59f89040bbbcbc9d2b90823e84651 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 18 Jun 2026 08:01:39 +0000 Subject: [PATCH 3/9] Co-locate enum name/value narrowing in createProperty Move the UnitEnum/BackedEnum interface `name` narrowing next to the existing concrete-enum `name`/`value` narrowing, so all the synthesized enum-property logic lives in one block instead of being split across the method. The interface branch produces non-falsy-string; `value` is deliberately left as its native int|string (a backing value may be "" or "0"), which the test now documents and guards with `Bar::value`. Co-Authored-By: Claude Opus 4.8 --- .../Php/PhpClassReflectionExtension.php | 54 ++++++++++--------- tests/PHPStan/Analyser/nsrt/bug-14839.php | 2 + 2 files changed, 32 insertions(+), 24 deletions(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index df9ad0cffcf..7e379414a39 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -206,32 +206,48 @@ private function createProperty( )); } - if ($declaringClassReflection->isEnum()) { + // The magic `name` and `value` properties on enums are synthesized by BetterReflection. + // `name` is declared on the `UnitEnum` interface (and inherited by `BackedEnum`); `value` on `BackedEnum`. + $isUnitEnumInterfaceNameProperty = $this->phpVersion->supportsEnums() + && $propertyName === 'name' + && $declaringClassName === 'UnitEnum'; + + if ($declaringClassReflection->isEnum() || $isUnitEnumInterfaceNameProperty) { if ( $propertyName === 'name' || ($declaringClassReflection->isBackedEnum() && $propertyName === 'value') ) { - $types = []; - foreach ($classReflection->getEnumCases() as $name => $case) { - if ($propertyName === 'name') { - $types[] = new ConstantStringType($name); - continue; - } + if ($declaringClassReflection->isEnum()) { + $types = []; + foreach ($classReflection->getEnumCases() as $name => $case) { + if ($propertyName === 'name') { + $types[] = new ConstantStringType($name); + continue; + } - $value = $case->getBackingValueType(); - if ($value === null) { - throw new ShouldNotHappenException(); + $value = $case->getBackingValueType(); + if ($value === null) { + throw new ShouldNotHappenException(); + } + + $types[] = $value; } - $types[] = $value; + $phpDocType = TypeCombinator::union(...$types); + $nativeType = new MixedType(); + } else { + // Accessed only through the `UnitEnum`/`BackedEnum` interface (or a template bound by it), + // so the concrete case names are unknown. Enum case names are valid PHP labels, so they + // can never be empty or "0", hence non-falsy-string. (The `value` of a backed enum is left + // as its native `int|string`, since a backing value may legitimately be "" or "0".) + $phpDocType = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType()); + $nativeType = new StringType(); } - $phpDocType = TypeCombinator::union(...$types); - return new PhpPropertyReflection( $declaringClassReflection, null, - new MixedType(), + $nativeType, $phpDocType, $phpDocType, $classReflection->getNativeReflection()->getProperty($propertyName), @@ -360,16 +376,6 @@ private function createProperty( ); } - if ( - $phpDocType === null - && $this->phpVersion->supportsEnums() - && $propertyName === 'name' - && $declaringClassName === 'UnitEnum' - ) { - // enum case names are valid PHP labels, so they can never be empty or "0" - $phpDocType = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType()); - } - $nativeType = TypehintHelper::decideTypeFromReflection($propertyReflection->getType(), selfClass: $declaringClassReflection); $declaringTrait = null; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14839.php b/tests/PHPStan/Analyser/nsrt/bug-14839.php index ae32a701e1c..db77f081f29 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14839.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14839.php @@ -26,8 +26,10 @@ function test(Foo $foo, Bar $bar, \UnitEnum $u, \BackedEnum $b): void { assertType("'A'|'B'", $foo->name); assertType("'A'|'B'", $bar->name); + assertType("'a'|'b'", $bar->value); assertType('non-falsy-string', $u->name); assertType('non-falsy-string', $b->name); + // `value` stays as its native `int|string`: unlike a case name, a backing value may legitimately be "" or "0". assertType('int|string', $b->value); } From 7845929f2afa83b2e4ec684c0730e8dd01e689d6 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 10:13:38 +0200 Subject: [PATCH 4/9] fix php7.4 build --- build/more-enum-adapter-errors.neon | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/build/more-enum-adapter-errors.neon b/build/more-enum-adapter-errors.neon index 05336963934..ea5dac676d0 100644 --- a/build/more-enum-adapter-errors.neon +++ b/build/more-enum-adapter-errors.neon @@ -36,3 +36,27 @@ parameters: rawMessage: 'Parameter #2 $reflection of method PHPStan\Reflection\ClassReflectionFactory::create() expects ReflectionClass, PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass|PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum given.' count: 1 path: ../src/Analyser/NodeScopeResolver.php + + - + rawMessage: Right side of || is always false. + identifier: booleanAnd.alwaysFalse + count: 1 + path: src/Reflection/Php/PhpClassReflectionExtension.php + + - + rawMessage: If condition is always true. + identifier: booleanAnd.alwaysFalse + count: 1 + path: src/Reflection/Php/PhpClassReflectionExtension.php + + - + rawMessage: Result of && is always false. + identifier: booleanAnd.alwaysFalse + count: 1 + path: src/Reflection/Php/PhpClassReflectionExtension.php + + - + rawMessage: 'Strict comparison using === between class-string and ''UnitEnum'' will always evaluate to false.' + identifier: notIdentical.alwaysTrue + count: 1 + path: src/Reflection/Php/PhpClassReflectionExtension.php From eb99c1abf91a9afa1bf561f06707467bec3872c5 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 10:16:33 +0200 Subject: [PATCH 5/9] Update more-enum-adapter-errors.neon --- build/more-enum-adapter-errors.neon | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build/more-enum-adapter-errors.neon b/build/more-enum-adapter-errors.neon index ea5dac676d0..7af4f6d06f4 100644 --- a/build/more-enum-adapter-errors.neon +++ b/build/more-enum-adapter-errors.neon @@ -41,22 +41,22 @@ parameters: rawMessage: Right side of || is always false. identifier: booleanAnd.alwaysFalse count: 1 - path: src/Reflection/Php/PhpClassReflectionExtension.php + path: ../src/Reflection/Php/PhpClassReflectionExtension.php - rawMessage: If condition is always true. identifier: booleanAnd.alwaysFalse count: 1 - path: src/Reflection/Php/PhpClassReflectionExtension.php + path: ../src/Reflection/Php/PhpClassReflectionExtension.php - rawMessage: Result of && is always false. identifier: booleanAnd.alwaysFalse count: 1 - path: src/Reflection/Php/PhpClassReflectionExtension.php + path: ../src/Reflection/Php/PhpClassReflectionExtension.php - rawMessage: 'Strict comparison using === between class-string and ''UnitEnum'' will always evaluate to false.' identifier: notIdentical.alwaysTrue count: 1 - path: src/Reflection/Php/PhpClassReflectionExtension.php + path: ../src/Reflection/Php/PhpClassReflectionExtension.php From 95d82478a15e004ba2844024eff111dd1a8b2c7f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 10:25:36 +0200 Subject: [PATCH 6/9] Update more-enum-adapter-errors.neon --- build/more-enum-adapter-errors.neon | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/build/more-enum-adapter-errors.neon b/build/more-enum-adapter-errors.neon index 7af4f6d06f4..9a1d09049f8 100644 --- a/build/more-enum-adapter-errors.neon +++ b/build/more-enum-adapter-errors.neon @@ -38,25 +38,21 @@ parameters: path: ../src/Analyser/NodeScopeResolver.php - - rawMessage: Right side of || is always false. - identifier: booleanAnd.alwaysFalse + rawMessage: 'Right side of || is always false.' count: 1 path: ../src/Reflection/Php/PhpClassReflectionExtension.php - - rawMessage: If condition is always true. - identifier: booleanAnd.alwaysFalse + rawMessage: 'If condition is always true.' count: 1 path: ../src/Reflection/Php/PhpClassReflectionExtension.php - - rawMessage: Result of && is always false. - identifier: booleanAnd.alwaysFalse + rawMessage: 'Result of && is always false.' count: 1 path: ../src/Reflection/Php/PhpClassReflectionExtension.php - rawMessage: 'Strict comparison using === between class-string and ''UnitEnum'' will always evaluate to false.' - identifier: notIdentical.alwaysTrue count: 1 path: ../src/Reflection/Php/PhpClassReflectionExtension.php From fec4c76c8d1a1a78573605d3176214bf269f6a59 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 18 Jun 2026 10:33:22 +0200 Subject: [PATCH 7/9] remove verbose comment. tests make it obvious --- src/Reflection/Php/PhpClassReflectionExtension.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 7e379414a39..c278717e8fe 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -206,8 +206,6 @@ private function createProperty( )); } - // The magic `name` and `value` properties on enums are synthesized by BetterReflection. - // `name` is declared on the `UnitEnum` interface (and inherited by `BackedEnum`); `value` on `BackedEnum`. $isUnitEnumInterfaceNameProperty = $this->phpVersion->supportsEnums() && $propertyName === 'name' && $declaringClassName === 'UnitEnum'; @@ -236,10 +234,6 @@ private function createProperty( $phpDocType = TypeCombinator::union(...$types); $nativeType = new MixedType(); } else { - // Accessed only through the `UnitEnum`/`BackedEnum` interface (or a template bound by it), - // so the concrete case names are unknown. Enum case names are valid PHP labels, so they - // can never be empty or "0", hence non-falsy-string. (The `value` of a backed enum is left - // as its native `int|string`, since a backing value may legitimately be "" or "0".) $phpDocType = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType()); $nativeType = new StringType(); } From ada64bf4368e9a67c395008c7c50d290ea93a6b1 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 19 Jun 2026 13:08:14 +0000 Subject: [PATCH 8/9] Also infer non-decimal-int-string for the magic enum name property An enum case name is a valid PHP label, so it must start with a letter or underscore and can never be a decimal integer string. Intersect the synthesized non-falsy-string with non-decimal-int-string for the UnitEnum/BackedEnum interface name property. Co-Authored-By: Claude Opus 4.8 --- src/Reflection/Php/PhpClassReflectionExtension.php | 7 ++++++- tests/PHPStan/Analyser/nsrt/bug-14839.php | 6 +++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index c278717e8fe..7a5f0c72cb0 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -39,6 +39,7 @@ use PHPStan\Reflection\SignatureMap\SignatureMapProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; @@ -234,7 +235,11 @@ private function createProperty( $phpDocType = TypeCombinator::union(...$types); $nativeType = new MixedType(); } else { - $phpDocType = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType()); + $phpDocType = TypeCombinator::intersect( + new StringType(), + new AccessoryNonFalsyStringType(), + new AccessoryDecimalIntegerStringType(true), + ); $nativeType = new StringType(); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14839.php b/tests/PHPStan/Analyser/nsrt/bug-14839.php index db77f081f29..371f29c4bd6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14839.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14839.php @@ -27,8 +27,8 @@ function test(Foo $foo, Bar $bar, \UnitEnum $u, \BackedEnum $b): void assertType("'A'|'B'", $foo->name); assertType("'A'|'B'", $bar->name); assertType("'a'|'b'", $bar->value); - assertType('non-falsy-string', $u->name); - assertType('non-falsy-string', $b->name); + assertType('non-decimal-int-string&non-falsy-string', $u->name); + assertType('non-decimal-int-string&non-falsy-string', $b->name); // `value` stays as its native `int|string`: unlike a case name, a backing value may legitimately be "" or "0". assertType('int|string', $b->value); } @@ -39,5 +39,5 @@ function test(Foo $foo, Bar $bar, \UnitEnum $u, \BackedEnum $b): void */ function testTemplate($enum): void { - assertType('non-falsy-string', $enum->name); + assertType('non-decimal-int-string&non-falsy-string', $enum->name); } From 700f2a0398be9c9de94f9c840e83427b60965b0b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 19 Jun 2026 15:12:00 +0200 Subject: [PATCH 9/9] Update PhpClassReflectionExtension.php --- src/Reflection/Php/PhpClassReflectionExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 7a5f0c72cb0..da15c0d6415 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -238,7 +238,7 @@ private function createProperty( $phpDocType = TypeCombinator::intersect( new StringType(), new AccessoryNonFalsyStringType(), - new AccessoryDecimalIntegerStringType(true), + new AccessoryDecimalIntegerStringType(inverse: true), ); $nativeType = new StringType(); }