Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ AOP via source transformation at load time (stream filter, no PECL, no eval).
Intercepts PHP class loading pipeline: source stream filter transforms source → injects interception hooks → caches result.
- Init: AspectKernel::init() → stream filter → transformers → configureAop()
- Main transformer: WeavingTransformer (class→trait, proxy class re-inherits parent+interfaces)
- Proxy dispatch: per-method static $__joinPoint → InterceptorInjector → advisor chain
- Proxy dispatch: per-method static $__joinPoint → InterceptorInjector → interceptor chain of first-class advice callables (The::aspect(X::class)->m(...), The::advice('id') for closure advices)

## Directory → AGENTS.md map
| Directory | Sub-AGENTS.md | Covers |
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ Changelog
======
4.0.0 (unreleased)
* [BC BREAK] Requires PHP 8.4+
* [Feature] **First-class callable advices** — the main way advices are now wired into woven code. Generated proxies reference each advice as a closure created with first-class callable syntax directly on the aspect instance, e.g. `Interceptor::before(The::aspect(MonitorAspect::class)->beforeMethodExecution(...))`, so the advice chain is plain, readable, IDE-navigable PHP with no lazy advisor indirection (`LazyAdvisorAccessor` is removed). Advices registered in the container as plain closures (not aspect methods) are resolved lazily through the new `The::advice('advisorId')` accessor, which unwraps `Advisor` and interceptor values down to the raw advice closure.
* [BC BREAK] **Aspect advice methods must be public.** Because generated proxies call advices as first-class callables on the aspect instance, an advice method annotated with `#[Before]`, `#[After]`, `#[Around]` or `#[AfterThrowing]` can no longer be `protected` or `private` — the aspect loader now throws an `AspectException` for non-public advice methods. Methods holding only a `#[Pointcut]` attribute may keep any visibility.
* [BC BREAK] Removed the `AdviceBefore`, `AdviceAfter` and `AdviceAround` marker interfaces. The `Advice` interface now requires `getType(): AdviceTypeEnum`, and the new `AdviceTypeEnum` backed enum (`Before`, `After`, `AfterThrowing`, `Around`, `Introduction`) carries both the advice kind and its invocation priority used for joinpoint sorting.
* [BC BREAK] Proxy engine switched from inheritance-based to **trait-based**: the original class body is converted to a PHP trait (`Foo__AopProxied`) and the proxy class uses it via `use` with private method aliases instead of extending the renamed class. This removes the `__AopProxied` parent from the inheritance chain.
* [BC BREAK] All invocation class constructors (`DynamicTraitAliasMethodInvocation`, `StaticTraitAliasMethodInvocation`, `ReflectionFunctionInvocation`) now require a `Closure $closureToCall` parameter (non-nullable). Generated proxy code always passes a first-class callable: `$this->__aop__method(...)` for own instance methods, `self::__aop__method(...)` for own static methods, `parent::method(...)` for inherited methods, and `\functionName(...)` for functions.
* [Feature] **Private method interception** — both dynamic (`private function foo()`) and static (`private static function bar()`) private methods can now be intercepted by aspects. This was impossible with the old extend-based engine because PHP does not allow overriding private methods in subclasses.
Expand Down
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ if ($fieldAccess->getField()->isInitialized($this)) {

- **Opcode cache friendly** — First-class support for **OPcache**. Transformed files and classes are stored as plain PHP files, fully optimized by your opcode cache just like regular code.

- **Smart caching** — Lazy loading of advice and aspects — only what's needed gets loaded. Joinpoints are resolved at compile-time and cached, eliminating runtime reflection costs.
- **Smart caching** — Advices are woven as **first-class callables** pointing straight at your aspect methods, joinpoints are resolved at compile-time and cached in the generated code — eliminating runtime reflection costs and lazy advisor indirection.

- **No runtime overhead** — Zero runtime annotation parsing, no slow `__call` methods, no proxy objects wrapping your instances. Method interception happens through direct, inlined PHP code — as fast as handwritten cross-cutting code. **Zero** overhead for non-intercepted methods.

Expand Down Expand Up @@ -231,6 +231,7 @@ $applicationAspectKernel->init([
### 4. Create an aspect

Aspect is the key element of AOP philosophy. Go! AOP framework just uses simple PHP classes for declaring aspects, which makes it possible to use all features of OOP for aspect classes.
Advices are declared as **public methods** of the aspect — the framework weaves them into your code as [first-class callables](https://www.php.net/manual/en/functions.first_class_callable_syntax.php), so every advice must be callable on the aspect instance from the outside (a `protected` or `private` advice method is rejected during aspect loading).
As an example, let's intercept all the methods and display their names:

```php
Expand Down Expand Up @@ -270,6 +271,36 @@ all dynamic public methods in the class Example. This is done with the help of a
`#[Before("execution(public Example->*(*))")]`
Hooks can be of any types, you will see them later.

#### Advices are first-class callables

There is no magic behind applying an aspect. For every intercepted method the framework
generates a plain, debuggable interceptor chain in which each advice is referenced as a
**closure created with first-class callable syntax** right on the aspect instance:

```php
static $__joinPoint = InterceptorInjector::forMethod(
self::class,
'doSomething',
[
Interceptor::before(The::aspect(MonitorAspect::class)->beforeMethodExecution(...)),
],
$this->__aop__doSomething(...),
);
```

`The::aspect()` fetches the aspect instance from the aspect container, and
`->beforeMethodExecution(...)` is the very advice method you wrote above — you can
Ctrl-click it in your IDE, set a breakpoint inside it, and step through the woven code as if
it were handwritten. This direct wiring is the main way advices are applied.

Advices that are registered in the container as plain closures (rather than aspect methods)
are woven through the lazy `The::advice()` accessor instead, which resolves the advisor by
its identifier and unwraps it down to the raw advice closure:

```php
Interceptor::around(The::advice('advisor.Demo\Aspect\DynamicMethodsAspect->aroundMagicMethods')),
```

### 5. Register the aspect in the aspect kernel

To register the aspect just add an instance of it in the `configureAop()` method of the kernel:
Expand Down
2 changes: 1 addition & 1 deletion demos/Demo/Aspect/CachingAspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class CachingAspect implements Aspect
* Real-life examples will use APC or Memcache to store value in the cache
*/
#[Around('@execution(Demo\Attribute\Cacheable)')]
protected function aroundCacheable(MethodInvocation $invocation): mixed
public function aroundCacheable(MethodInvocation $invocation): mixed
{
static $memoryCache = [];

Expand Down
2 changes: 1 addition & 1 deletion demos/Demo/Aspect/FluentInterfaceAspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class FluentInterfaceAspect implements Aspect
* Fluent interface advice
*/
#[Around('within(Demo\Aspect\FluentInterface+) && execution(public **->set*(*))')]
protected function aroundMethodExecution(MethodInvocation $invocation): mixed
public function aroundMethodExecution(MethodInvocation $invocation): mixed
{
$result = $invocation->proceed();

Expand Down
6 changes: 3 additions & 3 deletions demos/Demo/Aspect/HealthyLiveAspect.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ protected function humanEat(): void
* @param DynamicMethodInvocation<HumanDemo> $invocation
*/
#[Before('$this->humanEat')]
protected function washUpBeforeEat(DynamicMethodInvocation $invocation): void
public function washUpBeforeEat(DynamicMethodInvocation $invocation): void
{
$person = $invocation->getThis();
$person->washUp();
Expand All @@ -50,7 +50,7 @@ protected function washUpBeforeEat(DynamicMethodInvocation $invocation): void
* @param DynamicMethodInvocation<HumanDemo> $invocation
*/
#[After('$this->humanEat')]
protected function cleanTeethAfterEat(DynamicMethodInvocation $invocation): void
public function cleanTeethAfterEat(DynamicMethodInvocation $invocation): void
{
$person = $invocation->getThis();
$person->cleanTeeth();
Expand All @@ -62,7 +62,7 @@ protected function cleanTeethAfterEat(DynamicMethodInvocation $invocation): void
* @param DynamicMethodInvocation<HumanDemo> $invocation
*/
#[Before('execution(public Demo\Example\HumanDemo->sleep(*))')]
protected function cleanTeethBeforeSleep(DynamicMethodInvocation $invocation): void
public function cleanTeethBeforeSleep(DynamicMethodInvocation $invocation): void
{
$person = $invocation->getThis();
$person->cleanTeeth();
Expand Down
7 changes: 7 additions & 0 deletions src/Aop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ Proxy generators use TypeGenerator::renderTypeForPhpDoc() to emit V as 2nd gener
| ClassFieldAccess | FieldAccess | Property interception via native get/set hooks on proxied properties |
| StaticInitializationJoinpoint | ClassJoinpoint | Fired once after proxy class loaded via injectJoinPoints() |

## Advice wiring (src/Aop/Framework/)
- The — proxy-code accessor: aspect(X::class) fetches aspect from container; advice('advisorId') resolves container-backed closure advice (unwraps Advisor/AbstractInterceptor to raw Closure)
- Interceptor — factory facade for generated code: before()/after()/around()/afterThrowing(Closure, int $order=0)
- GeneratedInterceptor — internal descriptor built by AbstractJoinpoint::flatAndSortAdvices() via fromAdvice(); usesContainerAdvice=true when advice closure isn't scoped to an Aspect class
- AdviceTypeEnum — Advice::getType() kind + sorting priority (before → after/afterThrowing → around → introduction); replaced AdviceBefore/AdviceAfter/AdviceAround marker interfaces
- Advice methods MUST be public (FCC calls them on the aspect instance from generated code)

## Pointcuts (src/Aop/Pointcut/)
- LALR grammar: PointcutGrammar, PointcutParser, PointcutLexer, PointcutParseTable
- Combinators: AndPointcut, OrPointcut, NotPointcut, NamePointcut, AttributePointcut, ClassInheritancePointcut, MatchInheritedPointcut, ModifierPointcut, ReturnTypePointcut, TruePointcut
Expand Down
7 changes: 7 additions & 0 deletions src/Aop/Advice.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,11 @@
*/
interface Advice
{

/**
* Returns the Advice type
*
* @api
*/
public function getType(): AdviceTypeEnum;
}
22 changes: 0 additions & 22 deletions src/Aop/AdviceAfter.php

This file was deleted.

22 changes: 0 additions & 22 deletions src/Aop/AdviceAround.php

This file was deleted.

22 changes: 0 additions & 22 deletions src/Aop/AdviceBefore.php

This file was deleted.

50 changes: 50 additions & 0 deletions src/Aop/AdviceTypeEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);
/*
* Go! AOP framework
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*/

namespace Go\Aop;

/**
* Advice type enumeration
*
* @api
*/
enum AdviceTypeEnum: string
{
case After = 'after';
case AfterThrowing = 'afterThrowing';
case Around = 'around';
case Before = 'before';
case Introduction = 'introduction';

/**
* Compares the relative invocation priority against another advice type.
*
* Advices execute in the order before -> after (and after-throwing) -> around, matching the
* classic AOP interceptor chain where "around" wraps everything else.
*
* @api
*/
public function compareTo(self $other): int
{
return $this->sortWeight() <=> $other->sortWeight();
}

private function sortWeight(): int
{
return match ($this) {
self::Before => 0,
self::After, self::AfterThrowing => 1,
self::Around => 2,
self::Introduction => 3,
};
}
}
2 changes: 1 addition & 1 deletion src/Aop/Framework/AbstractInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ final public function __serialize(): array
final public function __unserialize(array $state): void
{
$state['adviceMethod'] = static::unserializeAdvice($state['adviceMethod']);
foreach ($state as $key => $value) {
foreach ($state + ['adviceOrder' => 0, 'pointcutExpression' => ''] as $key => $value) {
$this->$key = $value;
}
}
Expand Down
46 changes: 31 additions & 15 deletions src/Aop/Framework/AbstractJoinpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,8 @@
namespace Go\Aop\Framework;

use Go\Aop\Advice;
use Go\Aop\AdviceAfter;
use Go\Aop\AdviceAround;
use Go\Aop\AdviceBefore;
use Go\Aop\AspectException;
use Go\Aop\IntroductionInfo;
use Go\Aop\Intercept\Interceptor;
use Go\Aop\Intercept\Joinpoint;
use Go\Aop\OrderedAdvice;
Expand Down Expand Up @@ -52,40 +51,57 @@ public function __construct(protected readonly array $advices = []) {}
/**
* Sorts advices by priority
*
* @param array<Advice|Interceptor> $advices
* @param array<mixed> $advices
*
* @return array<Advice|Interceptor> Sorted list of advices
* @return array<mixed> Sorted list of advices
*/
public static function sortAdvices(array $advices): array
{
$sortedAdvices = $advices;
uasort(
$sortedAdvices,
fn(Advice $first, Advice $second) => match (true) {
$first instanceof AdviceBefore && !($second instanceof AdviceBefore) => -1,
$first instanceof AdviceAround && !($second instanceof AdviceAround) => 1,
$first instanceof AdviceAfter && !($second instanceof AdviceAfter) => $second instanceof AdviceBefore ? 1 : -1,
$first instanceof OrderedAdvice && $second instanceof OrderedAdvice => $first->getAdviceOrder() - $second->getAdviceOrder(),
default => 0,
function (mixed $first, mixed $second): int {
if ($first instanceof Advice && $second instanceof Advice) {
$priority = $first->getType()->compareTo($second->getType());
if ($priority !== 0) {
return $priority;
}
}

return $first instanceof OrderedAdvice && $second instanceof OrderedAdvice
? $first->getAdviceOrder() - $second->getAdviceOrder()
: 0;
}
);

return $sortedAdvices;
}

/**
* Replace concrete advices with list of ids
* Replace concrete advices with generated-code descriptors or introduction ids.
*
* @param array<string, array<string, array<string, Advice|Interceptor>>> $advices List of advices
* @param array<string, array<string, array<string, mixed>>> $advices List of advices
*
* @return array<string, array<string, array<string>>> Sorted identifier of advices/interceptors
* @return array<string, array<string, list<string|GeneratedInterceptor>>> Sorted advices/interceptors
*/
public static function flatAndSortAdvices(array $advices): array
{
$flattenAdvices = [];
foreach ($advices as $type => $typedAdvices) {
foreach ($typedAdvices as $name => $concreteAdvices) {
$flattenAdvices[$type][$name] = array_keys(self::sortAdvices($concreteAdvices));
foreach (self::sortAdvices($concreteAdvices) as $advisorId => $advice) {
if ($advice instanceof IntroductionInfo) {
$flattenAdvices[$type][$name][] = (string) $advisorId;

continue;
}
if (!$advice instanceof Advice) {
throw new AspectException(
"Advisor {$advisorId} provides " . get_debug_type($advice) . ' instead of advice instance'
);
}
$flattenAdvices[$type][$name][] = GeneratedInterceptor::fromAdvice((string) $advisorId, $advice);
}
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/Aop/Framework/AfterInterceptor.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@

namespace Go\Aop\Framework;

use Go\Aop\AdviceAfter;
use Go\Aop\AdviceTypeEnum;
use Go\Aop\Intercept\Joinpoint;

/**
* "After" interceptor
*
* @api
*/
final class AfterInterceptor extends AbstractInterceptor implements AdviceAfter
final class AfterInterceptor extends AbstractInterceptor
{
public function invoke(Joinpoint $joinpoint): mixed
{
Expand All @@ -30,4 +30,9 @@ public function invoke(Joinpoint $joinpoint): mixed
($this->adviceMethod)($joinpoint);
}
}

public function getType(): AdviceTypeEnum
{
return AdviceTypeEnum::After;
}
}
Loading