Skip to content

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

Merged
lisachenko merged 3 commits intomasterfrom
copilot/feature-support-first-class-callable
May 1, 2026
Merged

feat: Support first-class callable syntax for proxy method/function invocations#566
lisachenko merged 3 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(). PHP array unpacking preserves reference bindings, so pass-by-reference function arguments work correctly without needing ReflectionFunction::invokeArgs.

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, including testPassByReferenceIsForwarded which verifies that by-reference arguments (e.g. \preg_match(...) filling $matches) are correctly propagated back through the invocation chain.
  • 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(...));

Documentation

  • CHANGELOG.md: added [BC BREAK] entry for the required Closure $closureToCall constructor parameter and a [Feature] entry for first-class callable syntax support.
  • CLAUDE.md: updated invocation class descriptions to reflect the current FCC-based implementation, updated the proxy code example to include the 4th FCC argument, and corrected stale references to the old Closure::bind construction-time approach.

Copilot AI linked an issue Apr 29, 2026 that may be closed by this pull request
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
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 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)

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(...));

@lisachenko lisachenko force-pushed the copilot/feature-support-first-class-callable branch from d26b543 to 6510c45 Compare May 1, 2026 09:33
lisachenko and others added 2 commits May 1, 2026 12:37
- DynamicTraitAliasMethodInvocation: accept Closure $closureToCall;
  uses ReflectionFunction around it to reconstruct ReflectionMethod;
  calls ReflectionMethod->invokeArgs to support by-reference parameters in methods

- StaticTraitAliasMethodInvocation: accept Closure $closureToCall; wraps
  it in a forward_static_call_array shim so bindTo(null, $scope) correctly forwards
  late-static-binding class.

- ReflectionFunctionInvocation: accept Closure $closureToCall; calls it directly
  instead of ReflectionFunction::invokeArgs().

- InterceptorInjector: add required $closureToCall 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).

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

Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com>
@lisachenko lisachenko force-pushed the copilot/feature-support-first-class-callable branch from 6510c45 to 4361f06 Compare May 1, 2026 09:40
@lisachenko
Copy link
Copy Markdown
Member

@copilot add a test for function interception to correctly pass arguments by reference. If this test will fail - construct a ReflectionFunction not from name, but from given closureToCall and then return back to the ReflectionFunction->invokeArgs().

Also, update CLAUDE.md file with latest updates (ensure to keep them short and precise) and add a line into the Changelog file to include new feature

….md and CHANGELOG with FCC changes

Agent-Logs-Url: https://github.com/goaop/framework/sessions/cb25cbe1-a51e-454d-a45e-824c14372c49

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

Copilot AI commented May 1, 2026

@copilot add a test for function interception to correctly pass arguments by reference. If this test will fail - construct a ReflectionFunction not from name, but from given closureToCall and then ret...

Done in 547f377:

By-reference test: Added testPassByReferenceIsForwarded to ReflectionFunctionInvocationTest. It uses \preg_match(...) (which fills its $matches parameter by reference) to verify that the modification propagates back through the invocation chain. The test passes with the current ($this->closureToCall)(...$this->arguments) implementation — PHP array unpacking preserves reference bindings as long as the caller passes [&$var], so no change to ReflectionFunctionInvocation was needed.

CHANGELOG.md: Added two entries:

  • [BC BREAK] — invocation constructors now require a non-nullable Closure $closureToCall
  • [Feature] — first-class callable syntax in generated proxy code and invocation constructors

CLAUDE.md: Updated the invocation class descriptions to reflect the current FCC-based implementation (was describing the old Closure::bind approach), updated the proxy code example to include the 4th FCC argument, and replaced the outdated Closure::bind performance bullet.

Copilot AI requested a review from lisachenko May 1, 2026 09:56
@lisachenko lisachenko added this to the 4.0.0 milestone May 1, 2026
@lisachenko lisachenko merged commit 2fcd995 into master May 1, 2026
7 checks passed
@lisachenko lisachenko deleted the copilot/feature-support-first-class-callable branch May 1, 2026 10:15
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