From bafbfafbce98eb44747208cf871dca7d542d9372 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sat, 16 May 2026 19:35:29 +0000 Subject: [PATCH 1/8] Add implicit throw point for dynamic instantiation of non-final classes 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`, `class-string`, and `class-string` 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. --- src/Analyser/ExprHandler/NewHandler.php | 11 +++ .../CatchWithUnthrownExceptionRuleTest.php | 14 ++- .../Rules/Exceptions/data/bug-6574.php | 85 +++++++++++++++++++ .../Rules/Pure/PureFunctionRuleTest.php | 5 ++ tests/PHPStan/Rules/Pure/data/bug-6574.php | 23 +++++ 5 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-6574.php create mode 100644 tests/PHPStan/Rules/Pure/data/bug-6574.php diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 2e57336cb9e..70edad9decf 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -184,6 +184,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } + if ($classReflection !== null && $constructorReflection === null && !$classReflection->isFinal() && $this->implicitThrows) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'new', + sprintf('instantiation of class %s', $classReflection->getDisplayName()), + false, + ); + } + if ($parametersAcceptor !== null) { $normalizedExpr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; } diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 52be895f99b..61d516b3f4c 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -273,10 +273,6 @@ public function testBug4806(): void 'Dead catch - ArgumentCountError is never thrown in the try block.', 65, ], - [ - 'Dead catch - Throwable is never thrown in the try block.', - 119, - ], ]); } @@ -798,4 +794,14 @@ public function testBug14569(): void $this->analyse([__DIR__ . '/data/bug-14569.php'], []); } + public function testBug6574(): void + { + $this->analyse([__DIR__ . '/data/bug-6574.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 83, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php new file mode 100644 index 00000000000..eab5098f508 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -0,0 +1,85 @@ + $class */ +function interfaceWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function interfaceWithConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function abstractClassWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function abstractClassWithConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function nonFinalClassWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { + } +} + +/** @param class-string $class */ +function finalClassWithoutConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - final class with no constructor + } +} diff --git a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php index d4fd0d07518..cb9afa117a6 100644 --- a/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php @@ -225,4 +225,9 @@ public function testBug14557(): void $this->analyse([__DIR__ . '/data/bug-14557-function.php'], []); } + public function testBug6574(): void + { + $this->analyse([__DIR__ . '/data/bug-6574.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Pure/data/bug-6574.php b/tests/PHPStan/Rules/Pure/data/bug-6574.php new file mode 100644 index 00000000000..f025719a252 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-6574.php @@ -0,0 +1,23 @@ + $class */ +function interfaceWithoutConstructor(string $class): void +{ + new $class(); +} + +/** @param class-string $class */ +function abstractClassWithoutConstructor(string $class): void +{ + new $class(); +} From 5e985e7c0724e1024e92f8b0aa88aaa2da46111e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 20:02:21 +0000 Subject: [PATCH 2/8] Add test cases for @throws void constructors on interfaces and abstract classes Co-Authored-By: Claude Opus 4.6 --- .../CatchWithUnthrownExceptionRuleTest.php | 10 +++++- .../Rules/Exceptions/data/bug-6574.php | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 61d516b3f4c..4cb3f7e0183 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -799,7 +799,15 @@ public function testBug6574(): void $this->analyse([__DIR__ . '/data/bug-6574.php'], [ [ 'Dead catch - Exception is never thrown in the try block.', - 83, + 97, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 106, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 115, ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php index eab5098f508..76df06b8926 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -30,6 +30,20 @@ final class FinalClass { } +interface ThrowsVoidInterface +{ + /** @throws void */ + public function __construct(); +} + +abstract class AbstractThrowsVoid +{ + /** @throws void */ + public function __construct() + { + } +} + /** @param class-string $class */ function interfaceWithoutConstructor(string $class): void { @@ -83,3 +97,21 @@ function finalClassWithoutConstructor(string $class): void } catch (\Exception $e) { // dead catch - final class with no constructor } } + +/** @param class-string $class */ +function interfaceWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} + +/** @param class-string $class */ +function abstractClassWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} From f6eb7119507557d3f3d3339b156e6bba7c47264a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 20:06:11 +0000 Subject: [PATCH 3/8] Address review: make bug-4806 HasNoConstructor final, add @throws void 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 --- .../CatchWithUnthrownExceptionRuleTest.php | 4 +++ .../Rules/Exceptions/data/bug-4806.php | 2 +- .../Rules/Exceptions/data/bug-6574.php | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 4cb3f7e0183..e7e10cb9e93 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -273,6 +273,10 @@ public function testBug4806(): void 'Dead catch - ArgumentCountError is never thrown in the try block.', 65, ], + [ + 'Dead catch - Throwable is never thrown in the try block.', + 119, + ], ]); } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-4806.php b/tests/PHPStan/Rules/Exceptions/data/bug-4806.php index 78d1ae5d389..d9f4b7fa39b 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-4806.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-4806.php @@ -12,7 +12,7 @@ final public function __construct() } } -class HasNoConstructor +final class HasNoConstructor { } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php index 76df06b8926..be558445e06 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -26,6 +26,20 @@ class NonFinalClass { } +interface ThrowsVoidInterface +{ + /** @throws void */ + public function __construct(); +} + +abstract class AbstractThrowsVoid +{ + /** @throws void */ + public function __construct() + { + } +} + final class FinalClass { } @@ -89,6 +103,24 @@ function nonFinalClassWithoutConstructor(string $class): void } } +/** @param class-string $class */ +function interfaceWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} + +/** @param class-string $class */ +function abstractClassWithThrowsVoidConstructor(string $class): void +{ + try { + new $class(); + } catch (\Exception $e) { // dead catch - constructor is @throws void + } +} + /** @param class-string $class */ function finalClassWithoutConstructor(string $class): void { From fb4ea904eba52a3f80b10adccb74cd7857fdf029 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 16 May 2026 22:20:18 +0200 Subject: [PATCH 4/8] Fix duplicate --- .../Rules/Exceptions/data/bug-6574.php | 32 ------------------- 1 file changed, 32 deletions(-) diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php index be558445e06..d366912b8bc 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-6574.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6574.php @@ -44,20 +44,6 @@ final class FinalClass { } -interface ThrowsVoidInterface -{ - /** @throws void */ - public function __construct(); -} - -abstract class AbstractThrowsVoid -{ - /** @throws void */ - public function __construct() - { - } -} - /** @param class-string $class */ function interfaceWithoutConstructor(string $class): void { @@ -129,21 +115,3 @@ function finalClassWithoutConstructor(string $class): void } catch (\Exception $e) { // dead catch - final class with no constructor } } - -/** @param class-string $class */ -function interfaceWithThrowsVoidConstructor(string $class): void -{ - try { - new $class(); - } catch (\Exception $e) { // dead catch - constructor is @throws void - } -} - -/** @param class-string $class */ -function abstractClassWithThrowsVoidConstructor(string $class): void -{ - try { - new $class(); - } catch (\Exception $e) { // dead catch - constructor is @throws void - } -} From 89fb00ccbe8ca31018a7c8f7e98383c648764644 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 18 May 2026 15:11:42 +0000 Subject: [PATCH 5/8] Move dynamic instantiation throw/impure points into existing code paths 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 --- src/Analyser/ExprHandler/NewHandler.php | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 70edad9decf..6c287a527aa 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -172,7 +172,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, true); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); } else { $impurePoints[] = new ImpurePoint( @@ -184,17 +184,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } - if ($classReflection !== null && $constructorReflection === null && !$classReflection->isFinal() && $this->implicitThrows) { - $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'new', - sprintf('instantiation of class %s', $classReflection->getDisplayName()), - false, - ); - } - if ($parametersAcceptor !== null) { $normalizedExpr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; } @@ -215,6 +204,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } elseif ($classReflection === null) { $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); + } elseif (!$expr->class instanceof Name && !$expr->class instanceof Node\Stmt\Class_ && $constructorReflection === null && !$classReflection->isFinal() && $this->implicitThrows) { + $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } return new ExpressionResult( @@ -229,7 +220,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex /** * @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 { $constructorReflection = null; $parametersAcceptor = null; @@ -268,6 +259,14 @@ private function processConstructorReflection(string $className, New_ $expr, Mut 'instantiation of unknown class', false, ); + } elseif ($isDynamic && !$classReflection->isFinal()) { + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'new', + sprintf('instantiation of class %s', $classReflection->getDisplayName()), + false, + ); } return [$constructorReflection, $classReflection, $parametersAcceptor, $impurePoints]; From c72f378d60ce618c6c3a9db93873a2f250275872 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 18 May 2026 15:55:01 +0000 Subject: [PATCH 6/8] Use $isDynamic variable, merge elseif branches, drop implicitThrows guard 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 --- src/Analyser/ExprHandler/NewHandler.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 6c287a527aa..7f286811494 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -84,6 +84,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $parametersAcceptor = null; $constructorReflection = null; $classReflection = null; + $isDynamic = false; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -153,6 +154,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel()); } } else { + $isDynamic = true; $objectClasses = $scope->getType($expr)->getObjectClassNames(); if (count($objectClasses) === 1) { $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); @@ -172,7 +174,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, true); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, $isDynamic); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); } else { $impurePoints[] = new ImpurePoint( @@ -202,9 +204,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($constructorThrowPoint !== null) { $throwPoints[] = $constructorThrowPoint; } - } elseif ($classReflection === null) { - $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); - } elseif (!$expr->class instanceof Name && !$expr->class instanceof Node\Stmt\Class_ && $constructorReflection === null && !$classReflection->isFinal() && $this->implicitThrows) { + } elseif ($classReflection === null || ($isDynamic && $constructorReflection === null && !$classReflection->isFinal())) { $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } From 9a204945e7ae5863e9555ffecd372df9f6ecf65e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 18 May 2026 17:48:44 +0000 Subject: [PATCH 7/8] Remove default value from $isDynamic parameter Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/NewHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 7f286811494..45b2a3d87d2 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -93,7 +93,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, false); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); if ($parametersAcceptor !== null) { @@ -220,7 +220,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex /** * @return array{?MethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} */ - private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope, bool $isDynamic = false): array + private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope, bool $isDynamic): array { $constructorReflection = null; $parametersAcceptor = null; From 348389c69a308c7b129f0fb02132196949dd86d3 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 18 May 2026 19:53:13 +0200 Subject: [PATCH 8/8] Update NewHandler.php --- src/Analyser/ExprHandler/NewHandler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 45b2a3d87d2..57cc504a3a3 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -174,7 +174,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { - [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, $isDynamic); + [$constructorReflection, $classReflection, $parametersAcceptor, $constructorImpurePoints] = $this->processConstructorReflection($className, $expr, $scope, true); $impurePoints = array_merge($impurePoints, $constructorImpurePoints); } else { $impurePoints[] = new ImpurePoint(