From 9db06e74c6e0393005521dcf471f6d59bb461453 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:38:33 +0000 Subject: [PATCH 1/4] Preserve a declared `never` native return type on magic methods instead of overriding it * `PhpMethodFromParserNodeReflection` unconditionally replaced the declared native return type of magic methods with the type PHP mandates (`void` for `__clone`/`__destruct`/`__unset`/`__wakeup`/`__set`/`__unserialize`, `string` for `__toString`, `bool` for `__isset`, `array` for `__sleep`, `array` for `__serialize`). PHP however always accepts `never` on a magic method, so the declared `never` is now kept. * `MethodNeverRule` no longer reports `__construct()` and `__destruct()` - PHP does not allow declaring a return type on them at all, so the suggested fix was impossible to apply. * This also fixes `ReturnTypeRule` (a `return` statement inside a magic method declared `never` was silently accepted) and `MissingReturnRule` (an empty-bodied `__toString(): never` reported "should return string but return statement is missing"). * Probed and found already correct: `__set_state()` and `__debugInfo()` (they combine via `TypeCombinator::intersect()`, which already collapses to `never`), `PhpMethodReflection` (only applies the mandated type when no native return type is declared), property hooks and plain functions (no mandated return type). --- .../Php/PhpMethodFromParserNodeReflection.php | 57 +++++++------ src/Rules/Playground/MethodNeverRule.php | 7 ++ .../Rules/Methods/ReturnTypeRuleTest.php | 27 ++++++ .../data/never-magic-method-return-type.php | 57 +++++++++++++ .../Rules/Missing/MissingReturnRuleTest.php | 24 ++++++ .../Rules/Missing/data/never-magic-method.php | 43 ++++++++++ .../Rules/Playground/MethodNeverRuleTest.php | 8 ++ .../Rules/Playground/data/method-never.php | 85 +++++++++++++++++++ 8 files changed, 282 insertions(+), 26 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/never-magic-method-return-type.php create mode 100644 tests/PHPStan/Rules/Missing/data/never-magic-method.php diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 290e0cb4b76..54cc26ae5c6 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -18,6 +18,7 @@ use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; +use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; @@ -87,34 +88,38 @@ public function __construct( if ($this->isConstructor) { $realReturnType = new VoidType(); } - if (in_array($name, ['__destruct', '__unset', '__wakeup', '__clone'], true)) { - $realReturnType = new VoidType(); - } - if ($name === '__tostring') { - $realReturnType = new StringType(); - } - if ($name === '__isset') { - $realReturnType = new BooleanType(); - } - if ($name === '__sleep') { - $realReturnType = new ArrayType(new IntegerType(), new StringType()); - } - if ($name === '__set_state') { - $realReturnType = TypeCombinator::intersect(new ObjectWithoutClassType(), $realReturnType); - } - if ($name === '__set') { - $realReturnType = new VoidType(); - } + // PHP always accepts "never" as the declared return type of a magic method, + // even when it otherwise mandates a specific one. + if (!$realReturnType instanceof NeverType) { + if (in_array($name, ['__destruct', '__unset', '__wakeup', '__clone'], true)) { + $realReturnType = new VoidType(); + } + if ($name === '__tostring') { + $realReturnType = new StringType(); + } + if ($name === '__isset') { + $realReturnType = new BooleanType(); + } + if ($name === '__sleep') { + $realReturnType = new ArrayType(new IntegerType(), new StringType()); + } + if ($name === '__set_state') { + $realReturnType = TypeCombinator::intersect(new ObjectWithoutClassType(), $realReturnType); + } + if ($name === '__set') { + $realReturnType = new VoidType(); + } - if ($name === '__debuginfo') { - $realReturnType = TypeCombinator::intersect(new UnionType([new ArrayType(new MixedType(true), new MixedType(true)), new NullType()]), $realReturnType); - } + if ($name === '__debuginfo') { + $realReturnType = TypeCombinator::intersect(new UnionType([new ArrayType(new MixedType(true), new MixedType(true)), new NullType()]), $realReturnType); + } - if ($name === '__unserialize') { - $realReturnType = new VoidType(); - } - if ($name === '__serialize') { - $realReturnType = new ArrayType(new MixedType(true), new MixedType(true)); + if ($name === '__unserialize') { + $realReturnType = new VoidType(); + } + if ($name === '__serialize') { + $realReturnType = new ArrayType(new MixedType(true), new MixedType(true)); + } } parent::__construct( diff --git a/src/Rules/Playground/MethodNeverRule.php b/src/Rules/Playground/MethodNeverRule.php index fdef4e9396e..7fceba01abf 100644 --- a/src/Rules/Playground/MethodNeverRule.php +++ b/src/Rules/Playground/MethodNeverRule.php @@ -8,7 +8,9 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use function count; +use function in_array; use function sprintf; +use function strtolower; /** * @implements Rule @@ -33,6 +35,11 @@ public function processNode(Node $node, Scope $scope): array $method = $node->getMethodReflection(); + // PHP does not allow declaring a return type on these at all + if (in_array(strtolower($method->getName()), ['__construct', '__destruct'], true)) { + return []; + } + $returnType = $method->getReturnType(); $helperResult = $this->helper->shouldReturnNever($node, $returnType); if ($helperResult === false) { diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 718a35b370b..e3da13e1409 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1364,4 +1364,31 @@ public function testBug14893(): void $this->analyse([__DIR__ . '/data/bug-14893.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testNeverMagicMethodReturnType(): void + { + $this->analyse([__DIR__ . '/data/never-magic-method-return-type.php'], [ + [ + 'Method NeverMagicMethodReturnType\Foo::__clone() should never return but return statement found.', + 10, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__serialize() should never return but return statement found.', + 15, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__toString() should never return but return statement found.', + 20, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__isset() should never return but return statement found.', + 25, + ], + [ + 'Method NeverMagicMethodReturnType\Foo::__sleep() should never return but return statement found.', + 31, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/never-magic-method-return-type.php b/tests/PHPStan/Rules/Methods/data/never-magic-method-return-type.php new file mode 100644 index 00000000000..7efcae3a406 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/never-magic-method-return-type.php @@ -0,0 +1,57 @@ += 8.1 + +namespace NeverMagicMethodReturnType; + +class Foo +{ + + public function __clone(): never + { + return; + } + + public function __serialize(): never + { + return []; + } + + public function __toString(): never + { + return 'foo'; + } + + public function __isset($name): never + { + return true; + } + + public function __sleep(): never + { + if (rand(0, 1)) { + return ['a']; + } + + throw new \Exception(); + } + + public function __clone2(): never + { + throw new \Exception(); + } + +} + +class Bar +{ + + public function __clone(): void + { + return; + } + + public function __toString(): string + { + return 'foo'; + } + +} diff --git a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php index f7ce31dbcf8..b2c2560d01c 100644 --- a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php +++ b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php @@ -386,4 +386,28 @@ public function testBug5681(): void $this->analyse([__DIR__ . '/data/bug-5681.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testNeverMagicMethod(): void + { + $this->checkExplicitMixedMissingReturn = true; + $this->analyse([__DIR__ . '/data/never-magic-method.php'], [ + [ + 'Method MissingReturnNeverMagicMethod\Foo::__toString() should always throw an exception or terminate script execution but doesn\'t do that.', + 8, + ], + [ + 'Method MissingReturnNeverMagicMethod\Foo::__clone() should always throw an exception or terminate script execution but doesn\'t do that.', + 12, + ], + [ + 'Method MissingReturnNeverMagicMethod\Foo::__serialize() should always throw an exception or terminate script execution but doesn\'t do that.', + 16, + ], + [ + 'Method MissingReturnNeverMagicMethod\Foo::__isset() should always throw an exception or terminate script execution but doesn\'t do that.', + 20, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Missing/data/never-magic-method.php b/tests/PHPStan/Rules/Missing/data/never-magic-method.php new file mode 100644 index 00000000000..6eabb1af8fc --- /dev/null +++ b/tests/PHPStan/Rules/Missing/data/never-magic-method.php @@ -0,0 +1,43 @@ += 8.1 + +namespace MissingReturnNeverMagicMethod; + +class Foo +{ + + public function __toString(): never + { + } + + public function __clone(): never + { + } + + public function __serialize(): never + { + } + + public function __isset($name): never + { + } + + public function __sleep(): never + { + throw new \Exception(); + } + +} + +class Bar +{ + + public function __toString(): string + { + return 'foo'; + } + + public function __clone(): void + { + } + +} diff --git a/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php b/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php index 5715a8fc38e..a81fed1b5ce 100644 --- a/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php +++ b/tests/PHPStan/Rules/Playground/MethodNeverRuleTest.php @@ -33,6 +33,14 @@ public function testRule(): void 'Method MethodNever\Foo::doBaz() always terminates script execution, it should have return type "never".', 31, ], + [ + 'Method MethodNever\MagicMethodsWithoutNever::__clone() always throws an exception, it should have return type "never".', + 132, + ], + [ + 'Method MethodNever\MagicMethodsWithoutNever::__toString() always throws an exception, it should have return type "never".', + 137, + ], ]); } diff --git a/tests/PHPStan/Rules/Playground/data/method-never.php b/tests/PHPStan/Rules/Playground/data/method-never.php index 8353da4d4a6..3ccd2751f33 100644 --- a/tests/PHPStan/Rules/Playground/data/method-never.php +++ b/tests/PHPStan/Rules/Playground/data/method-never.php @@ -55,3 +55,88 @@ public function yields(): \Generator } } + +class MagicMethods +{ + + public function __construct() + { + throw new \Exception(); + } + + public function __destruct() + { + throw new \Exception(); + } + + public function __clone(): never + { + throw new \Exception(); + } + + public function __toString(): never + { + throw new \Exception(); + } + + public function __isset($name): never + { + throw new \Exception(); + } + + public function __set($name, $value): never + { + throw new \Exception(); + } + + public function __unset($name): never + { + throw new \Exception(); + } + + public function __sleep(): never + { + throw new \Exception(); + } + + public function __wakeup(): never + { + throw new \Exception(); + } + + public function __serialize(): never + { + throw new \Exception(); + } + + public function __unserialize(array $data): never + { + throw new \Exception(); + } + + public static function __set_state($properties): never + { + throw new \Exception(); + } + + public function __debugInfo(): never + { + throw new \Exception(); + } + +} + +class MagicMethodsWithoutNever +{ + + public function __clone(): void + { + throw new \Exception(); + } + + public function __toString(): string + { + throw new \Exception(); + } + +} From 42b4be1bd0766fc554a320063eb8d99c0b644ff1 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 11 Aug 2026 21:03:38 +0200 Subject: [PATCH 2/4] Update method-never.php --- tests/PHPStan/Rules/Playground/data/method-never.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Playground/data/method-never.php b/tests/PHPStan/Rules/Playground/data/method-never.php index 3ccd2751f33..430bf758a32 100644 --- a/tests/PHPStan/Rules/Playground/data/method-never.php +++ b/tests/PHPStan/Rules/Playground/data/method-never.php @@ -1,4 +1,4 @@ -= 8.1 namespace MethodNever; From e139b542dae51f76b008b1095efa6aa5d191d5b4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 11 Aug 2026 19:20:58 +0000 Subject: [PATCH 3/4] Gate the magic method `never` return type on PhpVersion `never` is only a valid return type since PHP 8.1, so the branch that keeps a declared `never` instead of the return type PHP mandates for a magic method is now guarded by a new PhpVersion::supportsNeverReturnType(). Co-Authored-By: Claude Opus 5 --- src/Analyser/MutatingScope.php | 2 + src/Php/PhpVersion.php | 5 +++ .../Php/PhpMethodFromParserNodeReflection.php | 8 ++-- .../Playground/MethodNeverRulePhp80Test.php | 42 +++++++++++++++++++ .../Playground/data/method-never-php-8.0.neon | 2 + .../Playground/data/method-never-php80.php | 18 ++++++++ 6 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Playground/MethodNeverRulePhp80Test.php create mode 100644 tests/PHPStan/Rules/Playground/data/method-never-php-8.0.neon create mode 100644 tests/PHPStan/Rules/Playground/data/method-never-php80.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index eab0c85e880..e387e2c6cab 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -1646,6 +1646,7 @@ public function enterClassMethod( $isConstructor, $this->attributeReflectionFactory->fromAttrGroups($classMethod->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $classMethod)), $phpDocPureUnlessCallableIsImpureParameters, + $this->phpVersion, ), !$classMethod->isStatic(), ); @@ -1736,6 +1737,7 @@ public function enterPropertyHook( false, $this->attributeReflectionFactory->fromAttrGroups($hook->attrGroups, InitializerExprContext::fromStubParameter($this->getClassReflection()->getName(), $this->getFile(), $hook)), [], + $this->phpVersion, ), true, ); diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 4ea71d7f8e9..ec52c85dfc7 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -362,6 +362,11 @@ public function supportsReadonlyPropertyReinitializationOnClone(): bool return $this->versionId >= 80300; } + public function supportsNeverReturnType(): bool + { + return $this->versionId >= 80100; + } + public function supportsNeverReturnTypeInArrowFunction(): bool { return $this->versionId >= 80200; diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 54cc26ae5c6..15eab7267b4 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PhpParser\Node\Stmt\ClassMethod; +use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\Reflection\Assertions; use PHPStan\Reflection\AttributeReflection; @@ -74,6 +75,7 @@ public function __construct( private bool $isConstructor, array $attributes, array $pureUnlessCallableIsImpureParameters, + PhpVersion $phpVersion, ) { if ($this->classMethod instanceof Node\PropertyHook) { @@ -88,9 +90,9 @@ public function __construct( if ($this->isConstructor) { $realReturnType = new VoidType(); } - // PHP always accepts "never" as the declared return type of a magic method, - // even when it otherwise mandates a specific one. - if (!$realReturnType instanceof NeverType) { + // Since PHP 8.1 "never" is always accepted as the declared return type of a magic method, + // even when PHP otherwise mandates a specific one. Before 8.1 "never" is just a class name. + if (!$phpVersion->supportsNeverReturnType() || !$realReturnType instanceof NeverType) { if (in_array($name, ['__destruct', '__unset', '__wakeup', '__clone'], true)) { $realReturnType = new VoidType(); } diff --git a/tests/PHPStan/Rules/Playground/MethodNeverRulePhp80Test.php b/tests/PHPStan/Rules/Playground/MethodNeverRulePhp80Test.php new file mode 100644 index 00000000000..a040b6b1824 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/MethodNeverRulePhp80Test.php @@ -0,0 +1,42 @@ + + */ +class MethodNeverRulePhp80Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new MethodNeverRule(new NeverRuleHelper()); + } + + public function testMagicMethods(): void + { + // "never" is not accepted as a magic method return type before PHP 8.1, + // so the return type PHP mandates still wins. + $this->analyse([__DIR__ . '/data/method-never-php80.php'], [ + [ + 'Method MethodNeverPhp80\MagicMethods::__clone() always throws an exception, it should have return type "never".', + 8, + ], + [ + 'Method MethodNeverPhp80\MagicMethods::__toString() always throws an exception, it should have return type "never".', + 13, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/method-never-php-8.0.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Playground/data/method-never-php-8.0.neon b/tests/PHPStan/Rules/Playground/data/method-never-php-8.0.neon new file mode 100644 index 00000000000..2e63367f9fe --- /dev/null +++ b/tests/PHPStan/Rules/Playground/data/method-never-php-8.0.neon @@ -0,0 +1,2 @@ +parameters: + phpVersion: 80000 diff --git a/tests/PHPStan/Rules/Playground/data/method-never-php80.php b/tests/PHPStan/Rules/Playground/data/method-never-php80.php new file mode 100644 index 00000000000..95888d336c2 --- /dev/null +++ b/tests/PHPStan/Rules/Playground/data/method-never-php80.php @@ -0,0 +1,18 @@ + Date: Wed, 12 Aug 2026 11:20:39 +0200 Subject: [PATCH 4/4] fix lint --- tests/PHPStan/Rules/Playground/data/method-never-php80.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Playground/data/method-never-php80.php b/tests/PHPStan/Rules/Playground/data/method-never-php80.php index 95888d336c2..d85a5b6678d 100644 --- a/tests/PHPStan/Rules/Playground/data/method-never-php80.php +++ b/tests/PHPStan/Rules/Playground/data/method-never-php80.php @@ -1,4 +1,4 @@ -= 8.1 namespace MethodNeverPhp80;