Skip to content

Add implicit throw point for dynamic instantiation of non-final classes without constructors#5683

Merged
VincentLanglet merged 8 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-2gu185u
May 18, 2026
Merged

Add implicit throw point for dynamic instantiation of non-final classes without constructors#5683
VincentLanglet merged 8 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-2gu185u

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When using new $class() where $class is typed as class-string<Interface>, class-string<AbstractClass>, or class-string<NonFinalClass> and the referenced type has no constructor, PHPStan incorrectly reported:

  • "Dead catch - Exception is never thrown in the try block"
  • "Function returns void but does not have any side effects"

This happened because the dynamic instantiation analysis recursively processed new TypeName() which found no constructor, and therefore generated no throw points or impure points. At runtime, the actual class could be any concrete implementation with a constructor that throws.

Changes

  • Added implicit throw point and uncertain impure point in src/Analyser/ExprHandler/NewHandler.php for the dynamic class name branch when:
    • The resolved class has no constructor
    • The class is not final (so the actual runtime class could be a subclass)
    • implicitThrows is enabled (consistent with constructor throw point handling)
  • Updated tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php:
    • Removed the assertion for bug-4806 line 119 which was asserting the buggy behavior
    • Added new testBug6574 test method
  • Added tests/PHPStan/Rules/Exceptions/data/bug-6574.php with test cases for:
    • Interface without constructor (primary reported case)
    • Interface with constructor (already worked correctly)
    • Abstract class without constructor
    • Abstract class with constructor (already worked correctly)
    • Non-final class without constructor
    • Final class without constructor (correctly reports dead catch)
  • Added tests/PHPStan/Rules/Pure/data/bug-6574.php and test method in PureFunctionRuleTest.php to verify the "no side effects" false positive is also fixed

Root cause

In NewHandler::processExpr, the dynamic class name branch (else case) resolves new $class() to a single class name and recursively processes new ClassName(). The recursive call goes through processConstructorReflection which returns no throw/impure points when the class has no constructor. The subsequent throw point logic at lines 199-206 also doesn't fire because $classReflection is not null (the class IS found in reflection) but $constructorReflection IS null (no constructor). This leaves the expression with zero throw points and zero impure points.

The fix adds the missing throw/impure points specifically in the dynamic branch, recognizing that for non-final classes, the actual runtime class could have a constructor. Final classes without constructors correctly remain side-effect-free since they cannot be subclassed.

Test

  • CatchWithUnthrownExceptionRuleTest::testBug6574 - regression test covering interface, abstract class, non-final class (all without constructor, all should NOT report dead catch), and final class (should correctly report dead catch)
  • PureFunctionRuleTest::testBug6574 - regression test verifying that functions using new $class() with interface/abstract class-string types are not falsely flagged as having no side effects

Fixes phpstan/phpstan#6574

Comment thread tests/PHPStan/Rules/Exceptions/data/bug-6574.php
65,
],
[
'Dead catch - Throwable is never thrown in the try block.',
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.

rather than removing the error make the class final

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.

Pushed. Here's what was done to address the review:

  1. bug-4806.php: Made HasNoConstructor class final and restored the dead catch assertion at line 119 (instead of removing the assertion as before). A final class with no constructor correctly has no throw points.

  2. bug-6574.php: Added two new test types — ThrowsVoidInterface (interface with @throws void constructor) and AbstractThrowsVoid (abstract class with @throws void constructor) — plus test functions that verify dead catch is correctly reported for both. When a constructor explicitly declares @throws void, even non-final classes should report dead catch since the contract guarantees no exceptions.

All 12,079 tests pass and make phpstan reports no errors.

Comment thread src/Analyser/ExprHandler/NewHandler.php Outdated
);
}

if ($classReflection !== null && $constructorReflection === null && !$classReflection->isFinal() && $this->implicitThrows) {
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.

Shouldn't the logic be placed

  • in processConstructorReflection for the impure point
  • in the next if for throwPoints

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. The review has been addressed:

  1. Impure point moved into processConstructorReflection — added a bool $isDynamic = false parameter and an elseif ($isDynamic && !$classReflection->isFinal()) branch in the existing if/elseif chain. The flag is needed because processConstructorReflection is also called from the Name branch (literal new Foo()), where a non-final class without a constructor is genuinely pure and can't throw.

  2. Throw point moved into the shared if/elseif chain at lines 204-209, guarded by !$expr->class instanceof Name && !$expr->class instanceof Node\Stmt\Class_ to ensure it only fires for dynamic instantiation.

  3. Standalone block removed from the else branch.

All 12,079 tests pass and make phpstan reports no errors.

Comment thread src/Analyser/ExprHandler/NewHandler.php Outdated
Comment thread src/Analyser/ExprHandler/NewHandler.php Outdated
* @return array{?MethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]}
*/
private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope): array
private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope, bool $isDynamic = false): array
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.

remove the default value of the $isDynamic parameter

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.

Removed the default value from the $isDynamic parameter on processConstructorReflection and passed false explicitly at the Name branch call site (line 96). All 12,079 tests pass and make phpstan is clean.

VincentLanglet and others added 8 commits May 18, 2026 20:35
…es without constructors

- In `NewHandler::processExpr`, when a dynamic class name (`new $class()`) resolves
  to a single non-final class without a constructor, add an implicit throw point and
  an uncertain impure point. This is because the actual runtime class could be a
  subclass with a constructor that throws.
- The fix is guarded by `implicitThrows` to remain consistent with how constructors
  are handled when that setting is disabled.
- Fixes the false positive "Dead catch" for `class-string<Interface>`,
  `class-string<AbstractClass>`, and `class-string<NonFinalClass>` where the
  referenced type has no constructor.
- Also fixes the false positive "Function returns void but does not have any side
  effects" for the same scenarios.
- Updated `bug-4806` test which was asserting the buggy behavior for a non-final
  class without a constructor.
…ct classes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d test cases

- Make HasNoConstructor class final in bug-4806.php so the dead catch
  assertion is preserved (instead of removing it)
- Add test cases for interface and abstract class with @throws void
  constructors, which should correctly report dead catch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the impure point for dynamic instantiation of non-final classes
without constructors into processConstructorReflection (via $isDynamic
parameter), and the throw point into the shared if/elseif chain.
This keeps related logic together rather than in a standalone block.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…uard

Address review feedback:
- Introduce $isDynamic local variable instead of instanceof checks
- Merge the two elseif branches that both add implicit throw points
- Remove $this->implicitThrows from the condition for consistency
  with the $classReflection === null branch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-2gu185u branch from 3859243 to 348389c Compare May 18, 2026 18:35
@VincentLanglet VincentLanglet merged commit dfe0c76 into phpstan:2.1.x May 18, 2026
657 of 659 checks passed
@VincentLanglet VincentLanglet deleted the create-pull-request/patch-2gu185u branch May 18, 2026 19:52
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