Skip to content

feat: Support first-class callable syntax for proxy method/function invocations#566

Open
Copilot wants to merge 7 commits intomasterfrom
copilot/feature-support-first-class-callable
Open

feat: Support first-class callable syntax for proxy method/function invocations#566
Copilot wants to merge 7 commits intomasterfrom
copilot/feature-support-first-class-callable

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 29, 2026

The framework previously relied on ReflectionMethod::invokeArgs / ReflectionFunction::invokeArgs and Closure::bind tricks inside invocation constructors to locate and call original methods. This adds first-class callable support, passing the original method/function reference directly into each joinpoint constructor as a required parameter.

Invocation classes

All $closureToCall parameters are now required (non-nullable) — this is a BC break for all invocation class constructors.

  • AbstractMethodInvocation: gains a required Closure $closureToCall parameter (4th argument) stored as protected readonly. Child classes may wrap or rebind it as needed.
  • DynamicTraitAliasMethodInvocation: stores the callable in the parent but dispatches via ReflectionMethod::invokeArgs($this->instance, $this->arguments) for reliability and performance. Internally resolves a private readonly ReflectionMethod $originalMethodToCall — the __aop__ alias for own methods, or getPrototype() for inherited ones. ReflectionMethod::invokeArgs is used because Closure::call() has known issues with pass-by-reference arguments (PHP bug #72326) and ReflectionMethod::invoke is surprisingly fast.
  • StaticTraitAliasMethodInvocation: wraps the callable in a static fn(array $args) => forward_static_call($callable, ...$args) shim stored in $closureToCall; bindTo(null, $scope) forwards the correct LSB class per call.
  • ReflectionFunctionInvocation: calls the callable directly via ($this->closureToCall)(...$this->arguments) in proceed().

InterceptorInjector

forMethod(), forStaticMethod(), and forFunction() each require a Closure $closureToCall parameter that is forwarded to the respective invocation constructor.

Proxy generators

Generated proxy method bodies now always pass a first-class callable as the 4th argument:

// Class-own method (has a __aop__ trait alias):
static $__joinPoint = InterceptorInjector::forMethod(self::class, 'getReport', [...], $this->__aop__getReport(...));

// Inherited method (no alias) — same pattern for both static and dynamic:
static $__joinPoint = InterceptorInjector::forMethod(self::class, 'getReport', [...], parent::getReport(...));

// Static method:
static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'create', [...], self::__aop__create(...));

// Function proxy (leading backslash = global function, avoids calling the namespace-scoped proxy):
static $__joinPoint = InterceptorInjector::forFunction('file_get_contents', [...], \file_get_contents(...));

Tests

  • testReflectionDispatchCallsEachInstanceCorrectly: verifies that the static singleton joinpoint correctly dispatches to each distinct caller instance via reflection.
  • testDispatchInvokesOriginalMethodBody: asserts that dispatch routes through the __aop__ alias body, not the overridden public method.
  • testInheritedMethodDispatchCallsEachInstanceCorrectly: verifies that inherited methods (no trait alias) dispatch correctly via getPrototype().
  • testFirstClassCallableLsbWithSubclassScope: verifies LSB is forwarded correctly when the static callable is invoked from a subclass scope.
  • ReflectionFunctionInvocationTest: covers the callable path for function interception.
  • testGenerateProxyForInheritedMethodDoesNotCreateTraitAlias (extended): asserts that the generated proxy for an inherited instance method contains parent::method(...) as the first-class callable.
  • testGenerateProxyForInheritedStaticMethodUsesParentCallable (new): asserts that the generated proxy for an inherited static method contains parent::method(...) as the first-class callable and no __aop__ alias, e.g.:
    static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'staticSelfPublic', [...], parent::staticSelfPublic(...));

Copilot AI and others added 2 commits April 29, 2026 15:46
- DynamicTraitAliasMethodInvocation: accept optional ?Closure $callable; when
  provided, use Closure::call() to rebind $this on every invocation instead of
  ReflectionMethod::invokeArgs(). Falls back to reflection for backward compat.

- StaticTraitAliasMethodInvocation: accept optional ?Closure $callable; wraps
  it in a forward_static_call shim so bindTo(null, $scope) correctly forwards
  late-static-binding class. Falls back to reflection for backward compat.

- ReflectionFunctionInvocation: accept optional ?Closure $callable; when
  provided, calls it directly instead of ReflectionFunction::invokeArgs().

- InterceptorInjector: add optional $callable parameter to forMethod(),
  forStaticMethod(), and forFunction(); passes it through to invocation ctors.

- ClassProxyGenerator: pass $this->__aop__method(...) or self::__aop__method(...)
  as 4th arg when method is declared in the proxied class (has a trait alias).
  Inherited methods continue to use reflection (parent:: first-class callables
  cannot be rebound via Closure::call() in PHP).

- TraitProxyGenerator / EnumProxyGenerator: always pass trait-alias callable
  since all intercepted methods in traits/enums have __aop__ aliases.

- FunctionProxyGenerator: pass \functionName(...) (global function reference)
  as 3rd arg to avoid calling namespace-scoped proxy recursively.

- Update all proxy snapshot fixture files.
- Add new tests for callable rebinding, LSB, and function invocation paths.
- Add TraitAliasProxy helpers and TraitAliasProxied::getObjectId stub method.

Agent-Logs-Url: https://github.com/goaop/framework/sessions/83ea73e0-1642-4248-a97d-ff448505cab4

Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com>
Copilot AI changed the title [WIP] Add support for first-class callable in method invocations feat: Support first-class callable syntax for proxy method/function invocations Apr 29, 2026
Copilot AI requested a review from lisachenko April 29, 2026 15:48
Comment thread src/Aop/Framework/DynamicTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/DynamicTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/DynamicTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/DynamicTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/DynamicTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/StaticTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/StaticTraitAliasMethodInvocation.php Outdated
Comment thread src/Proxy/ClassProxyGenerator.php Outdated
Comment thread src/Proxy/ClassProxyGenerator.php
Comment thread src/Proxy/ClassProxyGenerator.php Outdated
Agent-Logs-Url: https://github.com/goaop/framework/sessions/399178e5-1968-4337-992f-a1a469792932

Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com>
Comment thread src/Aop/Framework/AbstractMethodInvocation.php Outdated
Comment thread src/Aop/Framework/AbstractMethodInvocation.php Outdated
Comment thread src/Aop/Framework/AbstractMethodInvocation.php Outdated
Comment thread src/Aop/Framework/DynamicTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/DynamicTraitAliasMethodInvocation.php Outdated
Comment thread src/Aop/Framework/InterceptorInjector.php Outdated
Comment thread src/Aop/Framework/InterceptorInjector.php Outdated
Comment thread src/Aop/Framework/ReflectionFunctionInvocation.php Outdated
Comment thread src/Aop/Framework/ReflectionFunctionInvocation.php Outdated
Comment thread src/Proxy/ClassProxyGenerator.php Outdated
Copilot AI and others added 2 commits April 29, 2026 20:48
…asMethodInvocation, simplify docblocks, use parent::method(...) for inherited method callables

Agent-Logs-Url: https://github.com/goaop/framework/sessions/2e78dd0a-1006-4727-8e55-b4e67db11722

Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com>
…ix docblock wording in InterceptorInjector and ReflectionFunctionInvocation

Agent-Logs-Url: https://github.com/goaop/framework/sessions/2e78dd0a-1006-4727-8e55-b4e67db11722

Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com>
Copilot AI requested a review from lisachenko April 29, 2026 20:52
@lisachenko lisachenko marked this pull request as ready for review April 29, 2026 21:14
@lisachenko
Copy link
Copy Markdown
Member

@copilot phpstan is failing. Also, I haven't seen any examples in generated proxies where parent::method(...) will be used, could you please generate a test scenario of interception of inherited public or protected method from parent class and ensure it works (at least for static methods where this syntax is useful)

…ent::method(...) assertions for inherited proxy generation

Agent-Logs-Url: https://github.com/goaop/framework/sessions/6547dd4b-e999-43fd-ae7c-fa5f96aa9720

Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Apr 29, 2026

@copilot phpstan is failing. Also, I haven't seen any examples in generated proxies where parent::method(...) will be used, could you please generate a test scenario of interception of inherited pub...

Fixed in d26b543:

PHPStan: The error was a pre-existing nullCoalesce.offset warning in AopComposerLoader.php line 109. PHPStan correctly determined that $loader[0] always exists for array&callable types, so ?? null was redundant. Removed it — PHPStan now passes with no errors.

Generated proxy examples with parent::method(...): Added two tests to ClassProxyGeneratorTest:

  • testGenerateProxyForInheritedMethodDoesNotCreateTraitAlias (extended with parent:: assertion) — verifies an inherited instance method like FirstStatic::publicMethod() (declared in First) generates parent::publicMethod(...) as the callable:
    static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethod', ['test'], parent::publicMethod(...));
  • testGenerateProxyForInheritedStaticMethodUsesParentCallable (new) — verifies an inherited static method like FirstStatic::staticSelfPublic() generates parent::staticSelfPublic(...):
    static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'staticSelfPublic', ['test'], parent::staticSelfPublic(...));

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

[Feature] Support first-class callable for method invocations

2 participants