Skip to content

Mark throw points coming from a throw expression and keep them as an effect of an expression statement - #6471

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-wt88hws
Sep 18, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-wt88hws

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

throw new \ValueError('...') on a separate line started being reported as Expression "throw new \ValueError('...')" on a separate line does not do anything. after c7301f6 (Do not treat possible Error throws as an effect of an expression statement).

That commit filtered out every explicit throw point whose type is entirely a subtype of Error, so that pure stdlib calls documented with @throws \ValueError (sprintf(), strpos(), intdiv(), $a / $b, …) are reported as having no effect again. The filter was too broad: it also swallowed the throw point of a throw written by the user, which is the whole point of such a statement.

The fix keeps the filter for inferred throws, but marks throw points that come from a throw expression and always counts those as an effect of the statement.

Changes

  • src/Analyser/InternalThrowPoint.php, src/Analyser/ThrowPoint.php, src/Reflection/Callables/SimpleThrowPoint.php — new fromThrowExpr flag with an isFromThrowExpr() getter (optional constructor/factory argument, BC-safe), preserved by subtractCatchType() and by the conversions between the three classes.
  • src/Analyser/ExprHandler/ThrowHandler.php — the throw point created for a throw expression sets the flag.
  • src/Analyser/StmtHandler/ExpressionHandler.php — an explicit Error throw point is only ignored when it does not come from a written throw.
  • src/Analyser/ArgumentsHandler.php — the flag survives re-wrapping the throw points of immediately invoked closures, arrow functions and callables passed as arguments.
  • src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php, src/Analyser/ExprHandler/FuncCallHandler.php — the flag survives the round trip through ClosureType's SimpleThrowPoints, which is how an IIFE or a $fn() call gets its throw points.

Analogous cases probed and fixed with the same flag (all were reported before, all have a regression test now):

  • throw new \ValueError() and throw $e where $e is an \Error
  • $n ?? throw new \ValueError()
  • $c ? throw new \ValueError() : null and $n ?: throw new \ValueError()
  • $c && throw new \ValueError(), $c || throw new \ValueError()
  • a match arm that throws an \Error
  • an immediately invoked closure (function (): void { throw new \ValueError(); })()
  • calling a closure variable whose body throws an \Error
  • new class { public function __construct() { throw new \ValueError(); } }
  • a closure / arrow function / callable variable throwing an \Error, passed to an immediately-invoking function such as array_map()

Analogous cases probed and found already correct (no change needed):

  • $x |> (static fn ($v) => throw new \ValueError()) — the pipe statement was never reported.
  • Logical and / or / xor with a throw right operand.
  • A throwing callback passed to a user function/method without @param-immediately-invoked-callable: its throw points are not part of the call's throw points at all, with \Exception just as with \Error, so this is unrelated pre-existing behaviour.

Cases that intentionally keep the old behaviour, because the Error throw is only inferred from a signature or from an operation: $a / $b, $a % $b, match ($a) {…}, sprintf(), strpos(), intdiv(), array_combine(), new \DateTimeImmutable() and a pure user function with @throws \TypeError.

Root cause

ExpressionHandler decides whether to emit NoopExpressionNode (the node all the "has no effect" / "does not do anything" rules listen to) from the statement's impure points and explicit throw points. c7301f6 added an Error-is-a-supertype test to that filter, but a throw point does not record why it exists, so the filter could not tell intdiv($a, $b) (an Error documented on a pure function) from throw new \ValueError() (an Error the statement exists to throw). Recording the origin on the throw point and consulting it in the filter fixes the whole family at once — every construct that can host a throw expression (??, ?:, ternary, &&, ||, match arms, arguments, closure bodies) propagates its throw points through the same plumbing, so they are all fixed by preserving the flag along those paths.

Test

  • tests/PHPStan/Rules/DeadCode/data/bug-15258.php + NoopRuleTest::testBug15258() — the reproducer from the issue's playground link, expecting no errors.
  • tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php + NoopRuleTest::testErrorThrows() — extended with all the analogous constructs listed above; the file keeps asserting that $a / $b, $a % $b and match are still reported.
  • tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-throw-expr.php + CallToFunctionStatementWithoutSideEffectsRuleTest::testThrowExprInCallbackArgument()array_map() with a throwing arrow function, closure and closure variable is not reported, while array_map() with a non-throwing callback still is.

Every test above fails without the source change and passes with it. make tests is green; make phpstan reports only a pre-existing error in src/Analyser/ExprHandler/AssignHandler.php present on 2.3.x before this change.

Fixes phpstan/phpstan#15258

@ondrejmirtes
ondrejmirtes changed the base branch from 2.3.x to 2.2.x September 18, 2026 17:04
…n effect of an expression statement

* `InternalThrowPoint`, `ThrowPoint` and `SimpleThrowPoint` carry a new `fromThrowExpr` flag telling apart a `throw` written in the analysed code from a throw inferred from what a call or an operation can throw.
* `ThrowHandler` sets the flag, `subtractCatchType()` and the `InternalThrowPoint` <-> `ThrowPoint` <-> `SimpleThrowPoint` conversions preserve it.
* `ExpressionHandler` no longer drops an explicit `Error` throw point when it comes from a written `throw`, so such a statement no longer emits `NoopExpressionNode`.
* The flag survives the throw points of immediately invoked closures and arrow functions (`NodeScopeResolver::processArgs()`) and of closure types (`ClosureTypeResolver`, `FuncCallHandler`), so IIFEs, `$fn()` and callbacks passed to `array_map()` and friends are covered too.
* Statements whose `Error` throw is only inferred (`$a / $b`, `$a % $b`, `match`, `sprintf()`, `intdiv()`, a pure function with `@throws \TypeError`) keep being reported as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hxda92rChPGzpW6YxjyZdQ
@ondrejmirtes
ondrejmirtes force-pushed the create-pull-request/patch-wt88hws branch from 5a75a38 to 295fc9b Compare September 18, 2026 17:16
@ondrejmirtes
ondrejmirtes merged commit d3a36a9 into phpstan:2.2.x Sep 18, 2026
862 of 888 checks passed
@ondrejmirtes
ondrejmirtes deleted the create-pull-request/patch-wt88hws branch September 18, 2026 17:23
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.

Throwing an Error does not do anything?

2 participants