From 8a4fd71bfa6c043cd09749169220870ec0c098e6 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 16 May 2026 10:18:40 +0000 Subject: [PATCH] Do not report undefined property in isset/empty/?? for types without class names When checkDynamicProperties is enabled, PHPStan incorrectly reported "Access to an undefined property" for chained isset() checks on mixed types. After the first isset() narrows mixed to object&hasProperty(foo), the second isset($tmp->bar) was being flagged because pickProperty returned null for the intersection type that has no concrete class names. The fix skips the dynamic property check when the type has no class names (bare object or object&hasProperty), since we cannot know what properties such a type might have. Closes https://github.com/phpstan/phpstan/issues/13539 --- .../Properties/AccessPropertiesCheck.php | 4 ++ ...AnalyserWithCheckDynamicPropertiesTest.php | 4 +- .../Properties/AccessPropertiesRuleTest.php | 9 +++ .../Rules/Properties/data/bug-13539.php | 56 +++++++++++++++++++ 4 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-13539.php diff --git a/src/Rules/Properties/AccessPropertiesCheck.php b/src/Rules/Properties/AccessPropertiesCheck.php index b1e0514e8e3..97d79ba0341 100644 --- a/src/Rules/Properties/AccessPropertiesCheck.php +++ b/src/Rules/Properties/AccessPropertiesCheck.php @@ -140,6 +140,10 @@ private function processSingleProperty(Scope $scope, PropertyFetch $node, string return []; } + if (count($type->getObjectClassNames()) === 0) { + return []; + } + $maybePropertyReflection = $this->pickProperty($scope, $type, $name); if ($maybePropertyReflection !== null && $maybePropertyReflection->isDummy()->no()) { return []; diff --git a/tests/PHPStan/Analyser/AnalyserWithCheckDynamicPropertiesTest.php b/tests/PHPStan/Analyser/AnalyserWithCheckDynamicPropertiesTest.php index b6a160de932..5538225e419 100644 --- a/tests/PHPStan/Analyser/AnalyserWithCheckDynamicPropertiesTest.php +++ b/tests/PHPStan/Analyser/AnalyserWithCheckDynamicPropertiesTest.php @@ -12,9 +12,7 @@ class AnalyserWithCheckDynamicPropertiesTest extends PHPStanTestCase public function testBug13529(): void { $errors = $this->runAnalyse(__DIR__ . '/data/bug-13529.php'); - $this->assertCount(1, $errors); - $this->assertSame('Access to an undefined property object::$bar.', $errors[0]->getMessage()); - $this->assertSame(8, $errors[0]->getLine()); + $this->assertCount(0, $errors); } /** diff --git a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php index eeda2fb9222..84567b6e064 100644 --- a/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/AccessPropertiesRuleTest.php @@ -1272,4 +1272,13 @@ public function testBug13537(): void $this->analyse([__DIR__ . '/data/bug-13537.php'], $errors); } + public function testBug13539(): void + { + $this->checkThisOnly = false; + $this->checkUnionTypes = true; + $this->checkDynamicProperties = true; + + $this->analyse([__DIR__ . '/data/bug-13539.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13539.php b/tests/PHPStan/Rules/Properties/data/bug-13539.php new file mode 100644 index 00000000000..7606b0a6f4a --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-13539.php @@ -0,0 +1,56 @@ +foo) || !isset($tmp->bar)) { + } +} + +function works(string $x): void { + $tmp = json_decode($x, false); + + if (!isset($tmp->foo, $tmp->bar)) { + } +} + +function works_too(string $x): void { + /** @var \stdClass $tmp */ + $tmp = json_decode($x, false); + + if (!isset($tmp->foo) || !isset($tmp->bar)) { + } +} + +function threeProperties(string $x): void { + $tmp = json_decode($x, false); + + if (!isset($tmp->foo) || !isset($tmp->bar) || !isset($tmp->baz)) { + } +} + +function coalesceAfterIsset(string $x): void { + $tmp = json_decode($x, false); + + if (isset($tmp->foo)) { + $bar = $tmp->bar ?? null; + } +} + +function issetInAndChain(string $x): void { + $tmp = json_decode($x, false); + + if (isset($tmp->foo) && isset($tmp->bar)) { + } +} + +function emptyAfterIsset(string $x): void { + $tmp = json_decode($x, false); + + if (isset($tmp->foo)) { + if (empty($tmp->bar)) { + } + } +}