From 376b3774d9bf7ee0a4f7b817ac44098e21d75c28 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 15 Jul 2026 00:39:56 +0900 Subject: [PATCH 1/3] Resolve forward_static_call() return types with the caller's late static binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forward_static_call() and forward_static_call_array() are forwarding calls: they keep the caller's late static binding, unlike call_user_func() which resets static:: to the named class. Both previously fell through to the function map and returned mixed. Normalize the callable into an inner call like call_user_func() does; when it names a class and a method, resolve it as a static call on resolveTypeByName()'s result without the static-type reset, so the named class yields the caller-bound ancestor type when it is an ancestor and a plain object type otherwise — matching the runtime rule that the binding is only forwarded within the caller's own ancestry. The synthesized call is resolved directly instead of through MutatingScope::getType(), because its printed form would collide with a real, non-forwarding static call in the expression-type cache. --- src/Analyser/ArgumentsNormalizer.php | 90 +++++++++++++++++++ src/Analyser/ExprHandler/FuncCallHandler.php | 57 ++++++++++++ .../Analyser/nsrt/forward-static-call.php | 63 +++++++++++++ 3 files changed, 210 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/forward-static-call.php diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php index ae6fb6511ca..38d59a42247 100644 --- a/src/Analyser/ArgumentsNormalizer.php +++ b/src/Analyser/ArgumentsNormalizer.php @@ -3,12 +3,14 @@ namespace PHPStan\Analyser; use PhpParser\Node\Arg; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\New_; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; +use PhpParser\Node\Name\FullyQualified; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Scalar\String_; use PHPStan\Node\Expr\TypeExpr; @@ -17,16 +19,19 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantIntegerType; use function array_is_list; use function array_key_exists; use function array_keys; use function array_values; use function count; +use function explode; use function is_string; use function key; use function ksort; use function max; use function sprintf; +use function str_contains; /** * @api @@ -191,6 +196,91 @@ public static function reorderCallUserFuncArrayArguments( ), $acceptsNamedArguments]; } + /** + * @return array{ParametersAcceptor, FuncCall|StaticCall, TrinaryLogic}|null + */ + public static function reorderForwardStaticCallArguments( + FuncCall $forwardStaticCallCall, + Scope $scope, + ): ?array + { + $result = self::reorderCallUserFuncArguments($forwardStaticCallCall, $scope); + if ($result === null) { + return null; + } + + [$parametersAcceptor, $innerFuncCall, $acceptsNamedArguments] = $result; + + return [ + $parametersAcceptor, + self::createForwardingStaticCall($innerFuncCall, $scope) ?? $innerFuncCall, + $acceptsNamedArguments, + ]; + } + + /** + * @return array{ParametersAcceptor, FuncCall|StaticCall, TrinaryLogic}|null + */ + public static function reorderForwardStaticCallArrayArguments( + FuncCall $forwardStaticCallArrayCall, + Scope $scope, + ): ?array + { + $result = self::reorderCallUserFuncArrayArguments($forwardStaticCallArrayCall, $scope); + if ($result === null) { + return null; + } + + [$parametersAcceptor, $innerFuncCall, $acceptsNamedArguments] = $result; + + return [ + $parametersAcceptor, + self::createForwardingStaticCall($innerFuncCall, $scope) ?? $innerFuncCall, + $acceptsNamedArguments, + ]; + } + + /** + * Turns a normalized callable invocation into a static call marked as a forwarding call + * (a call that forwards the caller's late static binding, like self:: and parent:: do), + * when the callable names a class and a method. Returns null for other callables. + */ + private static function createForwardingStaticCall(FuncCall $funcCall, Scope $scope): ?StaticCall + { + $callableExpr = $funcCall->name; + if (!$callableExpr instanceof Expr) { + return null; + } + + $callableType = $scope->getType($callableExpr); + + $className = null; + $methodName = null; + $constantArrays = $callableType->getConstantArrays(); + $constantStrings = $callableType->getConstantStrings(); + if (count($constantArrays) === 1) { + $classStrings = $constantArrays[0]->getOffsetValueType(new ConstantIntegerType(0))->getConstantStrings(); + $methodStrings = $constantArrays[0]->getOffsetValueType(new ConstantIntegerType(1))->getConstantStrings(); + if (count($classStrings) === 1 && count($methodStrings) === 1) { + $className = $classStrings[0]->getValue(); + $methodName = $methodStrings[0]->getValue(); + } + } elseif (count($constantStrings) === 1 && str_contains($constantStrings[0]->getValue(), '::')) { + [$className, $methodName] = explode('::', $constantStrings[0]->getValue(), 2); + } + + if ($className === null || $className === '' || $methodName === null || $methodName === '') { + return null; + } + + return new StaticCall( + new FullyQualified($className), + new Identifier($methodName), + $funcCall->getArgs(), + $funcCall->getAttributes(), + ); + } + public static function reorderFuncArguments( ParametersAcceptor $parametersAcceptor, FuncCall $functionCall, diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 47efb2af7fe..3d6eb480c87 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -9,7 +9,9 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; @@ -18,6 +20,7 @@ use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; @@ -92,6 +95,7 @@ public function __construct( private ReflectionProvider $reflectionProvider, private DynamicThrowTypeExtensionProvider $dynamicThrowTypeExtensionProvider, private DynamicReturnTypeExtensionRegistryProvider $dynamicReturnTypeExtensionRegistryProvider, + private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, #[AutowiredParameter] @@ -867,6 +871,24 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } } + if ($functionReflection->getName() === 'forward_static_call') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArguments($expr, $scope); + if ($result !== null) { + [, $innerCall] = $result; + + return $this->resolveForwardStaticCallType($scope, $innerCall); + } + } + + if ($functionReflection->getName() === 'forward_static_call_array') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArrayArguments($expr, $scope); + if ($result !== null) { + [, $innerCall] = $result; + + return $this->resolveForwardStaticCallType($scope, $innerCall); + } + } + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( $scope, $expr->getArgs(), @@ -908,6 +930,41 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } + /** + * forward_static_call() is a forwarding call: it keeps the caller's late static binding + * (like self:: and parent:: do). The named class is therefore resolved without resetting + * static:: to it — resolveTypeByName() already yields the caller-bound ancestor type when + * the named class is an ancestor, and a plain object type otherwise, matching the runtime + * behavior of only forwarding within the caller's own ancestry. + * + * The synthesized static call is resolved here directly instead of through + * MutatingScope::getType(), because it would be indistinguishable from a real, + * non-forwarding static call on the same class in the scope's expression-type cache. + */ + private function resolveForwardStaticCallType(MutatingScope $scope, FuncCall|StaticCall $innerCall): Type + { + if ( + $innerCall instanceof StaticCall + && $innerCall->class instanceof Name + && $innerCall->name instanceof Identifier + ) { + $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( + $scope, + $scope->resolveTypeByName($innerCall->class), + $innerCall->name->toString(), + $innerCall, + ); + if ($callType !== null) { + return $callType; + } + + return new ErrorType(); + } + + // Closures and other callables have their own lexical scope; resolve them as-is. + return $scope->getType($innerCall); + } + public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes { if ($expr->name instanceof Name) { diff --git a/tests/PHPStan/Analyser/nsrt/forward-static-call.php b/tests/PHPStan/Analyser/nsrt/forward-static-call.php new file mode 100644 index 00000000000..6e6eb4f084d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/forward-static-call.php @@ -0,0 +1,63 @@ + 1)); + } + +} + +class Other +{ + + /** @return static */ + public static function create(): static + { + return new static(); // @phpstan-ignore new.static + } + +} + +function outsideClass(): void { + // Runtime would throw (no class scope is active), but the type is still resolvable. + assertType('ForwardStaticCall\Base', forward_static_call([Base::class, 'create'])); +} From 523ec08be7477fac9d37eaea632e656ba3db42fe Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 15 Jul 2026 00:47:38 +0900 Subject: [PATCH 2/3] Cover forward_static_call() across a multi-level hierarchy Root -> Middle -> Leaf (final), each with its own which() implementation carrying a distinct return type, mirroring the runtime behavior: - naming an ancestor calls the named class's implementation (overrides in the caller or below it do not re-dispatch) while the binding is forwarded, - the forwarded binding is the caller's static, collapsing to the class itself in a final class, - naming a descendant resolves to the named class's object type, which covers both runtime outcomes (binding forwarded when the runtime static is a subclass of the named class, reset to the named class otherwise), - an instance method has an active class scope to forward as well. --- .../nsrt/forward-static-call-multi-level.php | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php diff --git a/tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php b/tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php new file mode 100644 index 00000000000..02cf0161983 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/forward-static-call-multi-level.php @@ -0,0 +1,98 @@ + Middle -> Leaf (final). + * Each class has its own which() implementation with a distinct return type, so the + * assertions below can tell exactly which implementation the call is resolved against. + */ +class Root +{ + + /** @return 'Root' */ + public static function which(): string + { + return 'Root'; + } + + /** @return static */ + public static function create(): static + { + return new static(); // @phpstan-ignore new.static + } + +} + +class Middle extends Root +{ + + /** @return 'Middle' */ + public static function which(): string // @phpstan-ignore method.childReturnType + { + return 'Middle'; + } + + /** @return static */ + public static function make(): static + { + return new static(); // @phpstan-ignore new.static + } + + public static function test(): void + { + // Naming an ancestor calls the *named* class's implementation (runtime returns + // 'Root' even though Middle and Leaf override which()), while the late static + // binding is forwarded. + assertType("'Root'", forward_static_call([Root::class, 'which'])); + assertType("'Root'", forward_static_call('ForwardStaticCallMultiLevel\Root::which')); + + // The forwarded binding is the caller's static: at runtime Middle::test() gives + // Middle and Leaf::test() gives Leaf, both covered by static(Middle). + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call([Root::class, 'create'])); + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call('ForwardStaticCallMultiLevel\Root::create')); + + // self::class names this very class; its own implementation is called. + assertType("'Middle'", forward_static_call([self::class, 'which'])); + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call([self::class, 'make'])); + + // Naming a descendant: at runtime the binding is forwarded only when the caller's + // runtime static is a subclass of the named class (Leaf::test() gives Leaf, + // Middle::test() gives Leaf too because the binding resets to the named class), + // so the named class's object type covers both outcomes. + assertType('ForwardStaticCallMultiLevel\Leaf', forward_static_call([Leaf::class, 'create'])); + } + + public function instanceContext(): void + { + // An instance method also has an active class scope to forward + // (at runtime the binding is the object's class). + assertType('static(ForwardStaticCallMultiLevel\Middle)', forward_static_call([Root::class, 'create'])); + } + +} + +final class Leaf extends Middle +{ + + /** @return 'Leaf' */ + public static function which(): string // @phpstan-ignore method.childReturnType + { + return 'Leaf'; + } + + public static function test(): void + { + // Called from the final class, naming the two-levels-up ancestor: still the named + // class's implementation (runtime returns 'Root', not this class's override), and + // the forwarded binding collapses to the final class itself. + assertType("'Root'", forward_static_call([Root::class, 'which'])); + assertType('ForwardStaticCallMultiLevel\Leaf', forward_static_call([Root::class, 'create'])); + + // A method defined only in the intermediate class forwards the same way. + assertType('ForwardStaticCallMultiLevel\Leaf', forward_static_call([Middle::class, 'make'])); + } + +} From 9169eae2edd9481af44a68db1660ab0f38126a66 Mon Sep 17 00:00:00 2001 From: USAMI Kenta Date: Wed, 15 Jul 2026 00:58:14 +0900 Subject: [PATCH 3/3] Check arguments passed through forward_static_call() against the callable Extend CallUserFuncRule to forward_static_call() and forward_static_call_array(), reusing their argument normalization, so missing or mismatched arguments of the forwarded callable are reported like for call_user_func(). The synthesized static call's name nodes carry the original call's position attributes so the errors point at the call site. Nonexistent methods are already covered by the generic callable parameter check. --- src/Analyser/ArgumentsNormalizer.php | 6 ++-- src/Rules/Functions/CallUserFuncRule.php | 10 +++++++ .../Rules/Functions/CallUserFuncRuleTest.php | 26 +++++++++++++++++ .../Functions/data/forward-static-call.php | 29 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/forward-static-call.php diff --git a/src/Analyser/ArgumentsNormalizer.php b/src/Analyser/ArgumentsNormalizer.php index 38d59a42247..0cd686d1649 100644 --- a/src/Analyser/ArgumentsNormalizer.php +++ b/src/Analyser/ArgumentsNormalizer.php @@ -273,9 +273,11 @@ private static function createForwardingStaticCall(FuncCall $funcCall, Scope $sc return null; } + // The original call's position attributes are propagated to the name nodes too, + // so rule errors about the synthesized call point at the original call site. return new StaticCall( - new FullyQualified($className), - new Identifier($methodName), + new FullyQualified($className, $funcCall->getAttributes()), + new Identifier($methodName, $funcCall->getAttributes()), $funcCall->getArgs(), $funcCall->getAttributes(), ); diff --git a/src/Rules/Functions/CallUserFuncRule.php b/src/Rules/Functions/CallUserFuncRule.php index cfc8f1955be..64e7accaab1 100644 --- a/src/Rules/Functions/CallUserFuncRule.php +++ b/src/Rules/Functions/CallUserFuncRule.php @@ -62,6 +62,16 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $node, $scope, ); + } elseif ($functionName === 'forward_static_call') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArguments( + $node, + $scope, + ); + } elseif ($functionName === 'forward_static_call_array') { + $result = ArgumentsNormalizer::reorderForwardStaticCallArrayArguments( + $node, + $scope, + ); } else { return []; } diff --git a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php index a05d57f9457..5e6b182a839 100644 --- a/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallUserFuncRuleTest.php @@ -45,6 +45,32 @@ protected function getRule(): Rule ); } + public function testForwardStaticCall(): void + { + $this->analyse([__DIR__ . '/data/forward-static-call.php'], [ + [ + 'Callable passed to forward_static_call() invoked with 0 parameters, 1 required.', + 21, + ], + [ + 'Parameter #1 $name of callable passed to forward_static_call() expects string, int given.', + 22, + ], + [ + 'Callable passed to forward_static_call() invoked with 0 parameters, 1 required.', + 23, + ], + [ + 'Callable passed to forward_static_call_array() invoked with 0 parameters, 1 required.', + 25, + ], + [ + 'Parameter #1 $name of callable passed to forward_static_call_array() expects string, int given.', + 26, + ], + ]); + } + #[RequiresPhp('>= 8.0.0')] public function testRule(): void { diff --git a/tests/PHPStan/Rules/Functions/data/forward-static-call.php b/tests/PHPStan/Rules/Functions/data/forward-static-call.php new file mode 100644 index 00000000000..d1109a16b12 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/forward-static-call.php @@ -0,0 +1,29 @@ +