From 06e947986408ff22c2d84aec05bba48c402553b6 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:32:34 +0000 Subject: [PATCH 1/3] Suppress undefined static property error when `property_exists()` guard is present MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle class-string first argument in `PropertyExistsTypeSpecifyingExtension` by marking the `property_exists()` FuncCall expression as `true` in scope, instead of trying to intersect `HasPropertyType` with the class-string type (which produces `never` due to ObjectTypeTrait incompatibility) - Add a virtual `property_exists()` check in `AccessStaticPropertiesCheck` before reporting "Access to an undefined static property" — constructs a `property_exists(ClassName::class, 'propName')` FuncCall and checks if the scope evaluates it as `true` - Covers `static::$prop`, `self::$prop`, `ClassName::$prop`, and expression-based `$className::$prop` access patterns - Also works for the assign context via `AccessStaticPropertiesInAssignRule` which shares the same `AccessStaticPropertiesCheck` - Updated phpstan-baseline.neon to reflect one fewer `instanceof ConstantStringType` --- phpstan-baseline.neon | 2 +- .../AccessStaticPropertiesCheck.php | 19 +++++++ .../PropertyExistsTypeSpecifyingExtension.php | 34 ++++++++--- ...AccessStaticPropertiesInAssignRuleTest.php | 5 ++ .../AccessStaticPropertiesRuleTest.php | 5 ++ .../Rules/Properties/data/bug-2861-assign.php | 20 +++++++ .../Rules/Properties/data/bug-2861.php | 56 +++++++++++++++++++ 7 files changed, 133 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-2861-assign.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-2861.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index f6cd6fd5935..d7174f5db4b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1644,7 +1644,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType - count: 2 + count: 1 path: src/Type/Php/PropertyExistsTypeSpecifyingExtension.php - diff --git a/src/Rules/Properties/AccessStaticPropertiesCheck.php b/src/Rules/Properties/AccessStaticPropertiesCheck.php index 7ad441ffed9..c548a2f342c 100644 --- a/src/Rules/Properties/AccessStaticPropertiesCheck.php +++ b/src/Rules/Properties/AccessStaticPropertiesCheck.php @@ -3,8 +3,14 @@ namespace PHPStan\Rules\Properties; use PhpParser\Node; +use PhpParser\Node\Arg; +use PhpParser\Node\Expr\ClassConstFetch; +use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\StaticPropertyFetch; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\NullsafeOperatorHelper; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; @@ -256,6 +262,19 @@ private function processSingleProperty(Scope $scope, StaticPropertyFetch $node, ]); } + if ($node->class instanceof Name) { + $classExpr = new ClassConstFetch($node->class, new Identifier('class')); + } else { + $classExpr = $node->class; + } + $propertyExistsCall = new FuncCall(new FullyQualified('property_exists'), [ + new Arg($classExpr), + new Arg(new String_($name)), + ]); + if ($scope->getType($propertyExistsCall)->isTrue()->yes()) { + return []; + } + return array_merge($messages, [ RuleErrorBuilder::message(sprintf( 'Access to an undefined static property %s::$%s.', diff --git a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php index 3c6405295d8..26807506622 100644 --- a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php @@ -15,11 +15,13 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\Accessory\HasPropertyType; +use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\IntersectionType; use PHPStan\Type\ObjectWithoutClassType; +use PHPStan\Type\UnionType; use function count; #[AutowiredService] @@ -66,17 +68,35 @@ public function specifyTypes( } $objectType = $scope->getType($args[0]->value); - if ($objectType instanceof ConstantStringType) { - return new SpecifiedTypes([], []); - } elseif ($objectType->isObject()->yes()) { - $propertyNode = new PropertyFetch( + if ($objectType->isString()->yes()) { + return $this->typeSpecifier->create( + new FuncCall(new FullyQualified('property_exists'), $node->getRawArgs()), + new ConstantBooleanType(true), + $context, + $scope, + ); + } + + if (!$objectType->isObject()->yes()) { + return $this->typeSpecifier->create( $args[0]->value, - new Identifier($propertyNameType->getValue()), + new UnionType([ + new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($propertyNameType->getValue()), + ]), + new ClassStringType(), + ]), + $context, + $scope, ); - } else { - return new SpecifiedTypes([], []); } + $propertyNode = new PropertyFetch( + $args[0]->value, + new Identifier($propertyNameType->getValue()), + ); + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyNode, $scope); if ($propertyReflection !== null) { if (!$propertyReflection->isNative()) { diff --git a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php index ed52d66ccd7..f8ddba0d610 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesInAssignRuleTest.php @@ -80,6 +80,11 @@ public function testRuleExpressionNames(): void ]); } + public function testBug2861(): void + { + $this->analyse([__DIR__ . '/data/bug-2861-assign.php'], []); + } + #[RequiresPhp('>= 8.5.0')] public function testAsymmetricVisibility(): void { diff --git a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php index a411b751980..a6e80a4dff5 100644 --- a/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessStaticPropertiesRuleTest.php @@ -353,4 +353,9 @@ public function testBug8668Bis(): void ]); } + public function testBug2861(): void + { + $this->analyse([__DIR__ . '/data/bug-2861.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-2861-assign.php b/tests/PHPStan/Rules/Properties/data/bug-2861-assign.php new file mode 100644 index 00000000000..fe16cea9f74 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-2861-assign.php @@ -0,0 +1,20 @@ +value = $value; + } + + /** @return static|null */ + public static function getDefault() { + if (property_exists(static::class, 'default') && null !== static::$default) { + $obj = static::$default; + return new static($obj); + } + return null; + } +} + +class Foo { + use EnumTrait; + public const BLA = 'bla'; +} + +class Bar { + use EnumTrait; + public static $default = 'bla'; + public const BLA = 'bla'; +} + +class Baz { + use EnumTrait; + + /** @return static|null */ + public static function getDefault2() { + if (property_exists(self::class, 'default') && null !== self::$default) { + return new static(self::$default); + } + return null; + } +} + +class ExpressionBased { + /** + * @param class-string $className + */ + public static function test(string $className): void { + if (property_exists($className, 'default')) { + echo $className::$default; + } + } +} From 76954894a1c4612ba30d7b9f45ec0d635f5aa0ba Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 18:07:59 +0000 Subject: [PATCH 2/3] Add non-static property fetch tests for property_exists() guard Co-Authored-By: Claude Opus 4.6 --- .../AccessPropertiesInAssignRuleTest.php | 5 +++++ .../Rules/Properties/AccessPropertiesRuleTest.php | 8 ++++++++ .../Rules/Properties/data/bug-2861-assign.php | 13 +++++++++++++ tests/PHPStan/Rules/Properties/data/bug-2861.php | 15 +++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php index eee28bf8d30..b07e632d170 100644 --- a/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesInAssignRuleTest.php @@ -253,4 +253,9 @@ public function testCloneWith(): void ]); } + public function testBug2861(): void + { + $this->analyse([__DIR__ . '/data/bug-2861-assign.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php index dd60c3f9d48..0a6c1240901 100644 --- a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php @@ -1294,4 +1294,12 @@ public function testBug13539(): void ]); } + public function testBug2861(): void + { + $this->checkThisOnly = false; + $this->checkUnionTypes = true; + $this->checkDynamicProperties = true; + $this->analyse([__DIR__ . '/data/bug-2861.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-2861-assign.php b/tests/PHPStan/Rules/Properties/data/bug-2861-assign.php index fe16cea9f74..110afd8804d 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-2861-assign.php +++ b/tests/PHPStan/Rules/Properties/data/bug-2861-assign.php @@ -17,4 +17,17 @@ public static function testExpr(string $className): void { $className::$default = 'value'; } } + + public function testInstance(): void { + if (property_exists($this, 'default')) { + $this->default = 'value'; + } + } + + /** @param self $obj */ + public function testInstanceObj(self $obj): void { + if (property_exists($obj, 'default')) { + $obj->default = 'value'; + } + } } diff --git a/tests/PHPStan/Rules/Properties/data/bug-2861.php b/tests/PHPStan/Rules/Properties/data/bug-2861.php index c0fcb21f292..5fbb07ceefc 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-2861.php +++ b/tests/PHPStan/Rules/Properties/data/bug-2861.php @@ -54,3 +54,18 @@ public static function test(string $className): void { } } } + +class InstancePropertyAccess { + public function test(): void { + if (property_exists($this, 'default')) { + echo $this->default; + } + } + + /** @param self $obj */ + public function testObj(self $obj): void { + if (property_exists($obj, 'default')) { + echo $obj->default; + } + } +} From 42ffe26b3e873002006538c6391afed833b4d5a3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 18:13:20 +0000 Subject: [PATCH 3/3] Add tests for non-static property fetches and object|string type narrowing - Add instance property access tests (property_exists($this, 'default')) to bug-2861.php and bug-2861-assign.php test data files - Add corresponding test methods in AccessPropertiesRuleTest and AccessPropertiesInAssignRuleTest - Add nsrt test covering the !$objectType->isObject()->yes() branch in PropertyExistsTypeSpecifyingExtension for object|string first argument Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-2861.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-2861.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-2861.php b/tests/PHPStan/Analyser/nsrt/bug-2861.php new file mode 100644 index 00000000000..8142e859f9f --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-2861.php @@ -0,0 +1,23 @@ +