Skip to content

Merge scope after ?-> method call to account for argument short-circuiting#5456

Open
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-z1mfsd1
Open

Merge scope after ?-> method call to account for argument short-circuiting#5456
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-z1mfsd1

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

PHP's nullsafe operator ?-> short-circuits evaluation when the left operand is null, meaning method arguments are not evaluated. PHPStan was not accounting for this: variables assigned within nullsafe method call arguments were treated as "definitely defined" even though they might not be evaluated at all.

Changes

  • Modified src/Analyser/ExprHandler/NullsafeMethodCallHandler.php:
    • Save the scope before processing the nullsafe method call
    • After processing, if the var type is always null, use the pre-call scope (arguments are never evaluated)
    • If the var type is nullable (but not always null), merge the post-call scope with the pre-call scope so that variables assigned in arguments become "maybe defined"
    • If the var type is never null, no merging is needed (existing behavior preserved)

Root cause

NullsafeMethodCallHandler::processExpr() converts $date?->format($format = "Y-m-d") into a regular MethodCall and processes it unconditionally. This means $format gets added to the scope as "definitely defined". But in PHP, when $date is null, the entire method call including argument evaluation is short-circuited — $format is never assigned.

The fix mirrors how CoalesceHandler and BooleanAndHandler already handle conditional evaluation: by merging the scope from the "executed" path with the scope from the "not executed" path, making any newly-introduced variables "maybe defined".

Analogous cases probed:

  • NullsafePropertyFetchHandler: Property names are almost always Identifier nodes (not expressions), so there are no side effects to short-circuit. The rare case of $obj?->$name where $name has side effects is theoretical — skipped.
  • Chained nullsafe calls ($a?->b()?->c($x = 1)): Each ?-> is a separate NullsafeMethodCall node, so the fix applies independently at each level — works correctly.
  • Named arguments, nested assignments in arguments: Covered by the same merge logic since all arguments are processed within the MethodCall.

Test

  • tests/PHPStan/Rules/Variables/data/bug-10729.php — DefinedVariableRule test covering:
    • Nullable var: $format reported as "might not be defined" (the reported bug)
    • Non-nullable var: no error (control case)
    • Always-null var: $format reported as "Undefined variable" (arguments never evaluated)
    • Multiple arguments: both $format and $value reported as "might not be defined"
    • Nested assignment: $result = $date?->format($format = ...) with $format correctly "might not be defined"
    • Existing variable preservation: variables defined before the nullsafe call remain "definitely defined"
  • tests/PHPStan/Analyser/nsrt/bug-10729.php — Type inference test verifying correct types for variables assigned in nullsafe arguments

Fixes phpstan/phpstan#10729

…cuiting

- In NullsafeMethodCallHandler::processExpr(), merge the post-call scope
  with the pre-call scope when the var type is nullable, so variables
  assigned in arguments become "maybe defined" rather than "definitely defined"
- When the var type is always null, use the pre-call scope directly since
  arguments are never evaluated
- Add DefinedVariableRule regression tests for nullable, always-null, and
  multi-argument cases
- Add NSRT type inference tests verifying correct types after nullsafe calls
if ($varType->isNull()->yes()) {
// Arguments are never evaluated when the var is always null.
$scope = $scopeBeforeNullsafe;
} elseif (TypeCombinator::containsNull($varType)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could use $varType->isNull()->maybe() instead.
And use a variable for $varType->isNull() to avoid computing it 2 times.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's what I changed to address VincentLanglet's review:

  1. Cached $varType->isNull() in a $varIsNull variable to avoid computing it twice.

  2. Replaced TypeCombinator::containsNull($varType) with $varIsNull->maybe() — a cleaner check that uses the already-computed result.

  3. Updated a throw-points test that was affected by the change: doesntThrow() returns mixed (which can be null), so ?-> may short-circuit. The variable certainty expectations for $foo and $bar were updated from Yes to Maybe, which is actually the correct behavior.

…ll()

Address review feedback: cache $varType->isNull() in a variable to avoid
computing it twice, and use ->maybe() instead of TypeCombinator::containsNull()
for the nullable check.

Update throw-points test: doesntThrow() returns mixed which can be null,
so ?-> may short-circuit and variables assigned in the method name/arguments
should be "maybe defined", not "yes defined".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment on lines 41 to 49
function () {
try {
doesntThrow()?->{$foo = 1}($bar = 2);
} finally {
assertVariableCertainty(TrinaryLogic::createYes(), $foo);
assertVariableCertainty(TrinaryLogic::createYes(), $bar);
// doesntThrow() returns mixed which can be null, so ?-> may short-circuit
assertVariableCertainty(TrinaryLogic::createMaybe(), $foo);
assertVariableCertainty(TrinaryLogic::createMaybe(), $bar);
}
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add the same test with a doesntThrow2 method which returns something mixed~null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I addressed VincentLanglet's review by adding a new test case in tests/PHPStan/Analyser/nsrt/throw-points/php8/null-safe-method-call.php that tests the mixed~null scenario:

  • Calls doesntThrow() (which returns mixed), then narrows via if ($result === null) return to get mixed~null
  • Uses $result?->{$foo = 1}($bar = 2) — since $result is mixed~null, the ?-> can never short-circuit
  • Asserts that both $foo and $bar have TrinaryLogic::createYes() certainty (always defined)

All tests pass (11856 tests, 79364 assertions) and make phpstan reports no errors.

Add a companion test case where the left operand of ?-> is mixed~null
(obtained via null-narrowing). Since the value can never be null, ?->
never short-circuits and variables assigned in arguments have Yes certainty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
};

function () {
$result = doesntThrow();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call need to be chaine inside the try catch

try {
doesntThrow2()?->{$foo = 1}($bar = 2);
} finally {
		assertVariableCertainty(TrinaryLogic::createYes(), $foo);
		assertVariableCertainty(TrinaryLogic::createYes(), $bar);
	}

If mixed~null is complex to indicate in phpdoc, then maybe use object as return type of doesntThrow2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants