From e07489aa05217ae591732ca6adb037629f203277 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 19 Apr 2026 06:43:24 +0000 Subject: [PATCH 1/4] Do not record conditional expressions for `MethodCall`/`StaticCall` when assigned expression contains nullsafe chain - When assigning from a nullsafe method/property chain (e.g. `$x = $a->b()?->c()?->d()`), the TypeSpecifier decomposes the chain and produces sure types for intermediate sub-expressions like `$a->b()`. These were being recorded as conditional expressions tied to the assigned variable, causing false positives in `NullsafeMethodCallRule` and `NullsafePropertyFetchRule` when the same method was called again later. - Add `exprContainsNullsafe()` helper to detect nullsafe operators in the assigned expression - Skip recording MethodCall/StaticCall conditional expressions when the assigned expression contains any NullsafeMethodCall or NullsafePropertyFetch - The fix for #9455 (direct method call narrowing like `$hasA = $b->getA() !== null`) is preserved because those assigned expressions do not contain nullsafe operators - Also fixes the analogous case with NullsafePropertyFetch chains --- src/Analyser/ExprHandler/AssignHandler.php | 65 +++++++++++++++---- .../Methods/NullsafeMethodCallRuleTest.php | 6 ++ .../PHPStan/Rules/Methods/data/bug-14493.php | 63 ++++++++++++++++++ .../NullsafePropertyFetchRuleTest.php | 6 ++ .../Rules/Properties/data/bug-14493.php | 43 ++++++++++++ 5 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14493.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-14493.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 3ea0196e4aa..3e1f914786d 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -71,6 +71,7 @@ use function array_slice; use function count; use function in_array; +use function is_array; use function is_int; use function is_string; @@ -242,6 +243,7 @@ public function processAssignVar( $type = $scopeBeforeAssignEval->getType($assignedExpr); $conditionalExpressions = []; + $assignedExprContainsNullsafe = $this->exprContainsNullsafe($assignedExpr); if ($assignedExpr instanceof Ternary) { $if = $assignedExpr->if; if ($if === null) { @@ -259,23 +261,23 @@ public function processAssignVar( $truthyType->isSuperTypeOf($falseyType)->no() && $falseyType->isSuperTypeOf($truthyType)->no() ) { - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $assignedExprContainsNullsafe); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $assignedExprContainsNullsafe); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $assignedExprContainsNullsafe); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $assignedExprContainsNullsafe); } } $truthyType = TypeCombinator::removeFalsey($type); if ($truthyType !== $type) { $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $assignedExprContainsNullsafe); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $assignedExprContainsNullsafe); $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $assignedExprContainsNullsafe); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $assignedExprContainsNullsafe); } foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { @@ -304,13 +306,13 @@ public function processAssignVar( $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $assignedExprContainsNullsafe); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $assignedExprContainsNullsafe); $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $assignedExprContainsNullsafe); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $assignedExprContainsNullsafe); } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); @@ -850,7 +852,7 @@ private function unwrapAssign(Expr $expr): Expr * @param array $conditionalExpressions * @return array */ - private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType): array + private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, bool $assignedExprContainsNullsafe): array { foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $exprType]) { if ($expr instanceof Variable) { @@ -871,6 +873,10 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco continue; } + if ($assignedExprContainsNullsafe && ($expr instanceof MethodCall || $expr instanceof Expr\StaticCall)) { + continue; + } + if (!isset($conditionalExpressions[$exprString])) { $conditionalExpressions[$exprString] = []; } @@ -891,7 +897,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco * @param array $conditionalExpressions * @return array */ - private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType): array + private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, bool $assignedExprContainsNullsafe): array { foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $exprType]) { if ($expr instanceof Variable) { @@ -912,6 +918,10 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ continue; } + if ($assignedExprContainsNullsafe && ($expr instanceof MethodCall || $expr instanceof Expr\StaticCall)) { + continue; + } + if (!isset($conditionalExpressions[$exprString])) { $conditionalExpressions[$exprString] = []; } @@ -928,6 +938,33 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ return $conditionalExpressions; } + private function exprContainsNullsafe(Expr $expr): bool + { + if ($expr instanceof Expr\NullsafeMethodCall || $expr instanceof Expr\NullsafePropertyFetch) { + return true; + } + + foreach ($expr->getSubNodeNames() as $name) { + $subNode = $expr->{$name}; + if ($subNode instanceof Expr) { + if ($this->exprContainsNullsafe($subNode)) { + return true; + } + } elseif (is_array($subNode)) { + foreach ($subNode as $item) { + if ($item instanceof Expr && $this->exprContainsNullsafe($item)) { + return true; + } + if ($item instanceof Node\Arg && $this->exprContainsNullsafe($item->value)) { + return true; + } + } + } + } + + return false; + } + /** * @param list $dimFetchStack */ diff --git a/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php b/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php index 0d1bc3e1b6e..2941692d9ce 100644 --- a/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Methods/NullsafeMethodCallRuleTest.php @@ -80,4 +80,10 @@ public function testBug12222(): void $this->analyse([__DIR__ . '/data/bug-12222.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14493(): void + { + $this->analyse([__DIR__ . '/data/bug-14493.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14493.php b/tests/PHPStan/Rules/Methods/data/bug-14493.php new file mode 100644 index 00000000000..3b965cdd450 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14493.php @@ -0,0 +1,63 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14493; + +class OrderEntity { + static public function getStaticOrderCustomer(): ?OrderCustomerEntity + { + return new OrderCustomerEntity(); + } + + public function getOrderCustomer(): ?OrderCustomerEntity + { + return new OrderCustomerEntity(); + } +} + +class OrderCustomerEntity { + public function getCustomer(): ?CustomerEntity { return null; } + /** @return array|null */ + public function getVatIds(): ?array { return null; } +} + +class CustomerEntity { + final public const ACCOUNT_TYPE_BUSINESS = 'business'; + + public function getAccountType(): string { return ''; } +} + + +abstract class AbstractDocumentRenderer +{ + protected function doFoo(OrderEntity $order): bool + { + $customerType = $order->getOrderCustomer()?->getCustomer()?->getAccountType(); + if ($customerType !== CustomerEntity::ACCOUNT_TYPE_BUSINESS) { + return false; + } + + $vatIds = $order->getOrderCustomer()?->getVatIds(); + if (!is_array($vatIds)) { + return false; + } + + return true; + } + + protected function doBar(OrderEntity $order): bool + { + $customerType = $order::getStaticOrderCustomer()?->getCustomer()?->getAccountType(); + if ($customerType !== CustomerEntity::ACCOUNT_TYPE_BUSINESS) { + return false; + } + + $vatIds = $order::getStaticOrderCustomer()?->getVatIds(); + if (!is_array($vatIds)) { + return false; + } + + return true; + } +} diff --git a/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php b/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php index e4c37b08ca0..4dfd535fb16 100644 --- a/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php +++ b/tests/PHPStan/Rules/Properties/NullsafePropertyFetchRuleTest.php @@ -81,4 +81,10 @@ public function testBug6922(): void $this->analyse([__DIR__ . '/data/bug-6922.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testBug14493(): void + { + $this->analyse([__DIR__ . '/data/bug-14493.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-14493.php b/tests/PHPStan/Rules/Properties/data/bug-14493.php new file mode 100644 index 00000000000..9ffa2fc7f5e --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-14493.php @@ -0,0 +1,43 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14493NullsafeProperty; + +class OrderEntity { + public function getOrderCustomer(): ?OrderCustomerEntity + { + return new OrderCustomerEntity(); + } +} + +class OrderCustomerEntity { + public ?CustomerEntity $customer = null; + /** @var array|null */ + public ?array $vatIds = null; +} + +class CustomerEntity { + final public const ACCOUNT_TYPE_BUSINESS = 'business'; + + public string $accountType = ''; +} + + +abstract class AbstractDocumentRenderer +{ + protected function doFoo(OrderEntity $order): bool + { + $customerType = $order->getOrderCustomer()?->customer?->accountType; + if ($customerType !== CustomerEntity::ACCOUNT_TYPE_BUSINESS) { + return false; + } + + $vatIds = $order->getOrderCustomer()?->vatIds; + if (!is_array($vatIds)) { + return false; + } + + return true; + } +} From 0e8faf1c3cc1d71818427e70f917078dfce494ad Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 09:18:35 +0200 Subject: [PATCH 2/4] Update bug-14493.php --- tests/PHPStan/Rules/Methods/data/bug-14493.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Methods/data/bug-14493.php b/tests/PHPStan/Rules/Methods/data/bug-14493.php index 3b965cdd450..07b7e8d07d7 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-14493.php +++ b/tests/PHPStan/Rules/Methods/data/bug-14493.php @@ -23,7 +23,7 @@ public function getVatIds(): ?array { return null; } } class CustomerEntity { - final public const ACCOUNT_TYPE_BUSINESS = 'business'; + public const ACCOUNT_TYPE_BUSINESS = 'business'; public function getAccountType(): string { return ''; } } From c13c0a12e2f3f518c78934f7931b567dd6350671 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 09:19:04 +0200 Subject: [PATCH 3/4] Update bug-14493.php --- tests/PHPStan/Rules/Properties/data/bug-14493.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Properties/data/bug-14493.php b/tests/PHPStan/Rules/Properties/data/bug-14493.php index 9ffa2fc7f5e..37f7668d415 100644 --- a/tests/PHPStan/Rules/Properties/data/bug-14493.php +++ b/tests/PHPStan/Rules/Properties/data/bug-14493.php @@ -18,7 +18,7 @@ class OrderCustomerEntity { } class CustomerEntity { - final public const ACCOUNT_TYPE_BUSINESS = 'business'; + public const ACCOUNT_TYPE_BUSINESS = 'business'; public string $accountType = ''; } From 6a01645830949fa1c8aed07d522debffad9331f1 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sun, 19 Apr 2026 09:37:54 +0200 Subject: [PATCH 4/4] Update AssignHandler.php --- src/Analyser/ExprHandler/AssignHandler.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 3e1f914786d..f6d161265d1 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -955,9 +955,6 @@ private function exprContainsNullsafe(Expr $expr): bool if ($item instanceof Expr && $this->exprContainsNullsafe($item)) { return true; } - if ($item instanceof Node\Arg && $this->exprContainsNullsafe($item->value)) { - return true; - } } } }