Skip to content

Treat first-class callable NullsafeMethodCall as a MethodCall instead of throwing#5888

Open
phpstan-bot wants to merge 7 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-3pnp41f
Open

Treat first-class callable NullsafeMethodCall as a MethodCall instead of throwing#5888
phpstan-bot wants to merge 7 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-3pnp41f

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Using the first-class callable syntax together with the nullsafe operator
($foo?->bar(...)) crashed PHPStan with Internal error.. While this is a
fatal error in PHP itself ("Cannot combine nullsafe operator with Closure
creation"), PHPStan must not crash on it. This change makes the analyser treat
such an expression as a regular method-call first-class callable.

Changes

  • src/Analyser/NodeScopeResolver.php: in the first-class callable branch of
    processExprNode(), handle Expr\NullsafeMethodCall by building a
    MethodCallableNode from an equivalent MethodCall, instead of falling
    through to the throw new ShouldNotHappenException().
  • tests/PHPStan/Analyser/nsrt/bug-9746.php: regression test asserting the
    inferred types and (implicitly) that no internal error is thrown.

Root cause

processExprNode() enumerated the Expr\CallLike subclasses that support the
first-class callable syntax — FuncCall, MethodCall, StaticCall, New_
and threw ShouldNotHappenException for anything else. NullsafeMethodCall is
also an Expr\CallLike whose isFirstClassCallable() can be true (the parser
accepts $foo?->bar(...) even though PHP rejects it at runtime), so it hit the
else branch and crashed. It was the only Expr\CallLike subclass not covered.

Test

  • tests/PHPStan/Analyser/nsrt/bug-9746.php reproduces the original crash via
    $foo?->doFoo(...) (nullable and non-nullable receivers, plus an assignment)
    and asserts (Closure(): int)|null / Closure(): int. It errors with an
    internal error before the fix and passes after.
  • Verified manually that the expression no longer crashes in any context
    (statement, assignment, call argument, array element, return).

Analogous cases probed

  • The other first-class callable Expr\CallLike subclasses (FuncCall,
    MethodCall, StaticCall, New_) were already handled; NullsafeMethodCall
    was the only missing one.
  • MutatingScope::getKeepVoidType() already lists NullsafeMethodCall in its
    first-class-callable guard, and type resolution flows through
    NullsafeMethodCallHandler, so no internal error occurs when the expression
    is typed.

Fixes phpstan/phpstan#9746

…tead of throwing

- `NodeScopeResolver::processExprNode()` handled the first-class callable
  syntax (`foo(...)`) for `FuncCall`, `MethodCall`, `StaticCall` and `New_`,
  but `NullsafeMethodCall` (`$foo?->bar(...)`) fell through to the `else`
  branch and threw `ShouldNotHappenException`, surfacing as an internal error.
- `NullsafeMethodCall` is the only `Expr\CallLike` subclass that was missing
  from this branch. It is added by wrapping the call in a `MethodCallableNode`
  built from an equivalent `MethodCall`, since `$foo?->bar(...)` is a fatal
  error in PHP ("Cannot combine nullsafe operator with Closure creation") and
  must not crash the analyser.
- Type resolution already routed correctly through `NullsafeMethodCallHandler`,
  so no internal error occurs anywhere the expression is typed (assignment,
  argument, array element, return).

@staabm staabm left a comment

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.

Crash bug fixes need a AnalyserIntegrationTest

…ll crash

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed staabm's review by adding the requested AnalyserIntegrationTest coverage for the crash fix.

What I did

  1. New data file tests/PHPStan/Analyser/data/bug-9746.php — reproduces the original crash path (the first-class callable NullsafeMethodCall used as a statement, which is exactly what hit CallToMethodStatementWithoutSideEffectsRuleMutatingScope::resolveType()ShouldNotHappenException in the issue's stack trace).

  2. New test testBug9746() in AnalyserIntegrationTest — runs the full analyser over the file and asserts the three resulting errors, confirming analysis completes without an internal error.

Verification

  • With the fix: test passes (10 assertions).
  • Without the fix (temporarily reverted the committed NullsafeMethodCall branch): the test errors with the ShouldNotHappenException internal error — confirming it genuinely catches the crash.
  • make phpstan: No errors.

Committed as a2149b421 and pushed to create-pull-request/patch-3pnp41f. The existing nsrt/bug-9746.php type-inference test remains as-is; this adds the integration-level crash regression the reviewer asked for.

{
public function sayHello(?self $self): void
{
$self?->sayHello(...);

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.

we expect a Cannot combine nullsafe operator with Closure creation error on this line

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.

I've addressed staabm's review. Here's what I did:

The review

staabm pointed out that for $self?->sayHello(...) PHPStan should report a Cannot combine nullsafe operator with Closure creation error (matching PHP's fatal error), not just silently treat it as a valid method-call first-class callable.

Changes

  1. src/Node/MethodCallableNode.php — added a nullsafe flag (default false) plus an isNullsafe() accessor, so the node can record that it originated from a $foo?->bar(...) expression.

  2. src/Analyser/NodeScopeResolver.php — the NullsafeMethodCall first-class-callable branch now passes nullsafe: true when building the MethodCallableNode.

  3. src/Rules/Methods/MethodCallableRule.php — when the node is nullsafe, it reports a non-ignorable error Cannot combine nullsafe operator with Closure creation. (identifier nullsafe.firstClassCallable) and skips the normal method-existence check.

  4. Tests:

    • New tests/PHPStan/Rules/Methods/data/method-callable-nullsafe.php + testNullsafe() in MethodCallableRuleTest — direct rule-level coverage of the new error.
    • Fixed testBug9746() in AnalyserIntegrationTest, which was inconsistent with its data file (it referenced a Bug9746Integration namespace/file that no longer existed). It now asserts the actual two errors, including the nullsafe one.

Verification

  • testNullsafe fails without the source change (it instead reports Cannot call method doFoo() on Foo|null), confirming the test catches the regression; passes with the fix.
  • All affected test classes pass (197 tests).
  • make phpstan: No errors.

Committed as d31bdac35 and pushed.

…irst-class callable NullsafeMethodCall

$foo?->bar(...) is a fatal error in PHP, but PHPStan only silently
treated it as a regular method-call first-class callable. Carry a
nullsafe flag on MethodCallableNode and report the non-ignorable error
from MethodCallableRule.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread src/Node/MethodCallableNode.php Outdated
private Expr $var,
private Identifier|Expr $name,
private Expr\MethodCall $originalNode,
private bool $nullsafe = false,

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.

instead of adding a separate $nullsafe bool , can we instead carry this information in $originalNode with a instanceof NullsafeMethodCall check?

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 staabh's review by removing the separate $nullsafe bool and carrying the information in $originalNode itself.

Changes

  1. src/Node/MethodCallableNode.php — Dropped the private bool $nullsafe constructor parameter. The $originalNode field is now typed Expr\MethodCall|Expr\NullsafeMethodCall, and isNullsafe() derives the flag via $this->originalNode instanceof Expr\NullsafeMethodCall. getOriginalNode()'s return type widened accordingly.

  2. src/Analyser/NodeScopeResolver.php — The NullsafeMethodCall first-class-callable branch now passes the original NullsafeMethodCall straight into MethodCallableNode instead of building a synthetic MethodCall and a true flag.

  3. src/Node/ClassStatementsGatherer.php — The one consumer that needs a concrete MethodCall (feeding the @api Method\MethodCall collector, which doesn't accept NullsafeMethodCall) now converts the nullsafe node locally. This keeps the @api class untouched. The only other getOriginalNode() consumer (Printer) handles NullsafeMethodCall natively.

Verification

  • testNullsafe (rule-level) and testBug9746 (integration crash regression) both pass.
  • UnusedPrivateMethodRuleTest and CallToMethodStatementWithoutSideEffectsRuleTest pass (other consumers of the gathered method calls).
  • make phpstan: No errors.

This is a behavior-preserving refactor — the fix itself was already committed in d31bdac35; this only changes how the nullsafe flag is represented. Committed as acc872106 and pushed.

phpstan-bot and others added 2 commits June 17, 2026 05:31
…inal node

Instead of a separate `$nullsafe` constructor flag, store the original
`NullsafeMethodCall` in `MethodCallableNode::$originalNode` and derive
`isNullsafe()` from an `instanceof` check. ClassStatementsGatherer converts
the nullsafe node into a regular `MethodCall` when collecting it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

Internal error when using first class callable with NullsafeMethodCall

2 participants