Skip to content

Resolve forward_static_call() with the caller's late static binding#6051

Open
zonuexe wants to merge 3 commits into
phpstan:2.2.xfrom
zonuexe:feature/forward-static-call-return-type
Open

Resolve forward_static_call() with the caller's late static binding#6051
zonuexe wants to merge 3 commits into
phpstan:2.2.xfrom
zonuexe:feature/forward-static-call-return-type

Conversation

@zonuexe

@zonuexe zonuexe commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Closes phpstan/phpstan#14958

forward_static_call() / forward_static_call_array() are forwarding calls: they propagate the caller's late static binding, unlike call_user_func() which resets it to the named class. Both previously fell through to the function map and returned mixed, and their arguments were not checked against the callable.

Type inference

The callable is normalized into an inner call the same way call_user_func() does. When it names a class and a method ([Base::class, 'create'], 'Base::create'), the synthesized static call is resolved on Scope::resolveTypeByName()'s result without the static-type reset that StaticCallHandler applies to explicit class names — 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 (verified on PHP 8.5, including descendant and unrelated classes). Other callables (closures, …) resolve as before.

class Base {
    /** @return static */
    public static function create(): static { /* … */ }
}
class Caller extends Base {
    public static function test(): void {
        forward_static_call([Base::class, 'create']);  // static(Caller), was mixed
        call_user_func([Base::class, 'create']);       // Base (unchanged)
    }
}

The multi-level-hierarchy semantics are covered by tests in a separate commit: naming an ancestor calls the named class's implementation (overrides below do not re-dispatch) while the binding is forwarded, the binding collapses in a final class, instance methods forward as well, and generics resolve through the passed arguments.

One implementation note: the synthesized static call is resolved directly instead of through MutatingScope::getType(), because its printed form would collide with a real, non-forwarding static call on the same class in the expression-type cache.

Rule

CallUserFuncRule is extended to forward_static_call*, reusing the same normalization, so missing or mismatched arguments of the forwarded callable are reported like for call_user_func(). (A callable that is not callable at all — including a nonexistent method — was already reported by the generic parameter check.) The synthesized call's name nodes carry the original call's position attributes so the errors point at the call site.

zonuexe added 3 commits July 15, 2026 00:39
…tic binding

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.
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.
…able

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.
@zonuexe zonuexe force-pushed the feature/forward-static-call-return-type branch from 824a1e5 to 9169eae Compare July 14, 2026 16:35
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.

Support forward_static_call(): model the forwarded late static binding and check the callable's arguments

1 participant