Skip to content

Fix phpstan/phpstan#14323: Weird variable might not be defined behavior#5245

Merged
VincentLanglet merged 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-64lrzpf
Mar 18, 2026
Merged

Fix phpstan/phpstan#14323: Weird variable might not be defined behavior#5245
VincentLanglet merged 8 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-64lrzpf

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

When a variable was assigned inside constructor arguments (e.g., new Process([$command = $this->getCommand()])), PHPStan incorrectly reported "Undefined variable" in the catch block instead of "Variable might not be defined". This was inconsistent with the behavior when the same assignment was wrapped in array_merge().

Changes

  • Refactored src/Analyser/ExprHandler/NewHandler.php to defer constructor throw point creation until after processArgs() completes
  • Split processConstructorReflection() into two methods: one for reflection/impure points (before args), and createConstructorThrowPoints() for throw points (after args)
  • This matches the pattern already used by MethodCallHandler, StaticCallHandler, and FuncCallHandler which was fixed in Fix phpstan/phpstan#14318: False positive variable might not be defined #5240

Root cause

In NewHandler::processExpr(), constructor throw points were created in processConstructorReflection() which was called BEFORE processArgs(). The throw points captured the scope before argument processing, so variable assignments within constructor arguments (like $command = $this->getCommand()) were not reflected in the throw point scopes. When a catch block used those variables, PHPStan saw them as completely undefined rather than possibly defined.

The fix defers constructor throw point creation to after processArgs() returns, so the throw points use the updated scope that includes variable assignments from argument evaluation.

Test

Added regression test in tests/PHPStan/Rules/Variables/data/bug-14323.php covering four variants:

  • DbCommand: new Process(array_merge([$command = ...])) — implicit constructor throws
  • DbCommand2: new Process([$command = ...]) — same without array_merge (was the bug)
  • DbCommand3: new Process2(array_merge([$command = ...])) — explicit @throws on constructor
  • DbCommand4: new Process3(array_merge([$command = ...]))@throws void on constructor

Fixes phpstan/phpstan#14323

…xpression arguments

- Constructor throw points in NewHandler were created before processArgs(),
  using a scope that didn't include variable assignments from constructor arguments
- Refactored NewHandler to defer constructor throw point creation until after
  processArgs() completes, matching the pattern used by MethodCallHandler
- Split processConstructorReflection() to separate throw point creation into
  createConstructorThrowPoints() which uses the post-args scope
- Added regression test in tests/PHPStan/Rules/Variables/data/bug-14323.php
Copy link
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

In the previous PR we had

$methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope);

Let's try to build this fix with a similar naming/signature.

…ttern

Remove createConstructorThrowPoints() method and call getConstructorThrowPoint()
directly at the call site, matching how MethodCallHandler uses getMethodThrowPoint().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

Done. Removed the intermediate createConstructorThrowPoints() method and inlined the call to getConstructorThrowPoint() directly at the call site, matching the pattern used by MethodCallHandler with getMethodThrowPoint(). All tests pass and static analysis is clean.

Copy link
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

classFound is $classReflection !== null
and classReflection can be accessed from constructorReflection

So I don't think processConstructorReflection signature need to change.

…ssFound from return

Address review feedback: classFound is equivalent to checking
reflectionProvider->hasClass() and classReflection can be obtained
from the reflectionProvider at the call site, so
processConstructorReflection's signature doesn't need to change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

Both background tasks completed successfully - static analysis and tests all pass. Nothing more to do here.

@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-64lrzpf branch from f1eba82 to dc2324f Compare March 18, 2026 16:37
@phpstan-bot
Copy link
Collaborator Author

Already retrieved this result — all 11,602 tests passed. The PR is approved and pushed. Nothing more to do.

@VincentLanglet VincentLanglet self-requested a review March 18, 2026 17:23
Copy link
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

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

I added two new tests DbCommand5 and DbCommand6 which are not consistent.

DbCommand5 reports a maybe undefined variable while it shouldn't

It's basically the same than DbCommand6 but just with mustRun throwing extra LogicException which are not caught (and occurs AFTER the variable definition).

When a method declares `@throws A|B` and a catch block catches `A`,
the check for direct explicit non-throw match was using
`$catchType->isSuperTypeOf($throwType)->yes()` which returns `maybe`
for union throw types (since the catch doesn't cover `B`).

This caused implicit throw points to be included unnecessarily,
leading to false "variable might not be defined" reports.

The fix decomposes union throw types and checks each member
individually, so `A` in `A|B` is recognized as a direct match
for `catch (A)`. Non-union types retain the original behavior,
preserving correctness for cases like `@throws RuntimeException`
caught as `PDOException`.

Fixes inconsistency between DbCommand5 and DbCommand6 test cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phpstan-bot
Copy link
Collaborator Author

Already handled — this was the final test run (11,602 tests passing) that confirmed the fix. Everything is committed and pushed.

@VincentLanglet
Copy link
Contributor

cc @staabm

I basically revert https://github.com/phpstan/phpstan-src/pull/5166/changes because with more tests and try I find the changes wrong.
It was checking the implicit throwPoint if an exact match was not done. But the real issue of the issue phpstan/phpstan#9349 was the fact $rs->fetch(\PDO::FETCH_ASSOC) throws an implicit throwPoint and not an explicit one. And this is because $pdo is just declared global $pdo so it's mixed and not the PDO class.
By changing to $pdo = new PDO() the tests worked as expected.

But the first fix proposed by the bot was still useful for the issue phpstan/phpstan#14323 to be consistent with #5240.
Kinda funny I found this because of the regression.

@VincentLanglet VincentLanglet requested a review from staabm March 18, 2026 18:15
Copy link
Contributor

@staabm staabm left a comment

Choose a reason for hiding this comment

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

Nice :)

@phpstan-bot
Copy link
Collaborator Author

Already retrieved this result — all 11,602 tests passed. The PR is approved by both reviewers, static analysis and tests are green. Nothing more to do.

@VincentLanglet VincentLanglet merged commit 6bac0de into phpstan:2.1.x Mar 18, 2026
651 of 652 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-64lrzpf branch March 18, 2026 20:56
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