This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Go! AOP Framework — an Aspect-Oriented Programming (AOP) framework for PHP 8.4+. It intercepts PHP class/method/function execution transparently by transforming source code at load time via a custom PHP stream wrapper, without requiring PECL extensions, annotations at runtime, or eval.
Package: goaop/framework | Namespace root: Go\ | PHP: ^8.4.0
- Agents must run on PHP 8.4 or higher.
- If the runtime is PHP 8.3.x, stop immediately and report that test/phpstan validation cannot be performed in that environment.
# Install dependencies
composer install
# Run full test suite
./vendor/bin/phpunit
# Run a single test file
./vendor/bin/phpunit tests/Go/Core/ContainerTest.php
# Run a single test method
./vendor/bin/phpunit --filter testMethodName tests/Go/Core/ContainerTest.php
# Static analysis (PHPStan level 10, src/ only)
./vendor/bin/phpstan analyze --memory-limit=512M
# CLI debugging tools
./bin/aspect debug:advisors [class]
./bin/aspect debug:pointcuts [expression]The framework works by intercepting PHP's class loading pipeline. When a class is loaded, the stream wrapper transforms its source code to inject interception hooks, then stores the result in a cache directory. The transformed class contains calls into the advisor chain for each matched join point.
AspectKernel::init()(src/Core/AspectKernel.php) — singleton, registers stream wrapper, builds transformer chain, callsconfigureAop()where users register aspectsSourceTransformingLoader::register()(src/Instrument/ClassLoading/SourceTransformingLoader.php) — PHP stream wrapper that interceptsinclude/requirevia thego-aop-php://protocolAopComposerLoader::init()(src/Instrument/ClassLoading/AopComposerLoader.php) — hooks into Composer's autoloader to redirect loads through the stream wrapperCachingTransformer— outer transformer that manages cache; on cache miss, invokes the inner transformers and writes the result
Applied in order for each loaded file:
ConstructorExecutionTransformer— transformsnewexpressions (whenINTERCEPT_INITIALIZATIONSfeature enabled)FilterInjectorTransformer— wrapsinclude/require(whenINTERCEPT_INCLUDESenabled)WeavingTransformer— main transformer; usesAdviceMatcherto find applicable advices andCachedAspectLoaderfor aspect metadata, then delegates to proxy generatorsMagicConstantTransformer— rewrites__FILE__/__DIR__so they resolve to the original file, not the cached proxy
Note:
SelfValueTransformerwas removed in 4.0 —self::in traits resolves to the using class naturally.
Each transformer returns TransformerResultEnum: RESULT_TRANSFORMED, RESULT_ABSTAIN, or RESULT_ABORTED.
WeavingTransformer converts the original class to a PHP trait and writes a proxy class that uses it. The two generated files for a class Ns\Foo are:
Woven file (replaces the original in the load stream):
// Original class body converted to a trait; final/abstract/extends/implements stripped
trait Foo__AopProxied { /* original methods verbatim */ }
include_once AOP_CACHE_DIR . '/_proxies/.../Foo.php';Proxy file (loaded by the include_once above):
class Foo extends OriginalParent implements OriginalInterfaces, \Go\Aop\Proxy
{
use \Ns\Foo__AopProxied {
\Ns\Foo__AopProxied::interceptedMethod as private __aop__interceptedMethod;
// ... one alias per intercepted method (including private ones)
}
public function interceptedMethod(ArgType $arg): ReturnType {
/** @var \Go\Aop\Intercept\DynamicMethodInvocation<self, ReturnType> $__joinPoint */
static $__joinPoint = \Go\Aop\Framework\InterceptorInjector::forMethod(self::class, 'interceptedMethod', [...], $this->__aop__interceptedMethod(...));
return $__joinPoint->__invoke($this, [$arg]);
}
// ... one override per intercepted method
}Key properties of this engine:
- The proxy class re-inherits the original parent and interfaces (read from reflection, not from the woven source).
self::in the trait body resolves toFoo(the proxy class) — no rewriting needed.- Private methods can be intercepted (impossible with the old extend-based engine).
- Each generated proxy method passes a first-class callable as the 4th arg to
InterceptorInjector:$this->__aop__method(...)for own dynamic methods,self::__aop__method(...)for own static methods,parent::method(...)for inherited methods (no trait alias), and\functionName(...)for function proxies.
ClassProxyGenerator— generates the trait-based proxy class for a regular class- Takes
$traitName(theFoo__AopProxiedFQCN) as second constructor arg - Always emits
use $traitNameeven when no methods are intercepted (introduction-only aspects) - Adds
__construct as private __aop____constructalias when the class defines its own constructor and properties are intercepted
- Takes
FunctionProxyGenerator— generates function wrappersTraitProxyGenerator— generates trait proxies (uses oldadjustOriginalClasspath)src/Proxy/Part/— individual code-generation components:InterceptedMethodGenerator— wraps a single method with join-point delegationInterceptedConstructorGenerator— wraps constructor; usesself::class(notparent::class) forClosure::bindToscope; calls$this->__aop____construct()when constructor is in the traitInterceptedPropertyGenerator— re-declares intercepted properties in the proxy with native PHP 8.4get/sethooks that route throughClassFieldAccess
src/Proxy/Generator/— low-level AST generators:ClassGenerator— builds the proxy class AST node;addTraitAlias()registers both the trait and an alias in a singleuse { ... }block; deduplicates traitsAttributeGroupsGenerator— copies PHP 8 attributes from reflection to proxy AST, preserving named argumentsTypeGenerator— converts PHP types (string orReflectionType) to AST nodes or phpDoc strings;renderTypeForPhpDoc()is used by all proxy generators to produce the second generic argument in@varannotations for$__joinPoint
src/Aop/Intercept/— interfaces:Joinpoint,Invocation,MethodInvocation,ConstructorInvocation,FunctionInvocation,FieldAccess- Generic template variables — all callable join-point interfaces carry PHPStan generics to enable type-aware static analysis in aspect advice:
MethodInvocation<T of object, V = mixed>—Tis the class holding the method (narrowsgetThis()/getScope());Vis the method return type (narrows__invoke()return)DynamicMethodInvocation<T, V>andStaticMethodInvocation<T, V>— subtypes with covariant narrowing (getThis()always returnsT/ alwaysnullrespectively)FunctionInvocation<V = mixed>—Vis the function return typeFieldAccess<T of object, V = mixed>—Tis the declaring class;Vis the property type (narrowsgetValue(),getValueToSet(),__invoke()return)
- The proxy generators (
ClassProxyGenerator,TraitProxyGenerator,EnumProxyGenerator,FunctionProxyGenerator) useTypeGenerator::renderTypeForPhpDoc()to extract the return type fromReflectionMethod/ReflectionFunctionand emit it as the second generic argument in the per-method@varannotation. This gives IDE and PHPStan full type-awareness on$__joinPoint->__invoke(...)calls.
- Generic template variables — all callable join-point interfaces carry PHPStan generics to enable type-aware static analysis in aspect advice:
src/Aop/Framework/— concrete invocation implementations:AbstractMethodInvocation— base class; holdsprotected readonly Closure $closureToCall(required, 4th constructor argument) — a first-class callable to the original method body;TRAIT_ALIAS_PREFIX = '__aop__'constant; manages recursive/cross-call stack framesDynamicTraitAliasMethodInvocation— instance-method invocation; receives$this->__aop__method(...)(own methods) orparent::method(...)(inherited) as$closureToCall; resolves aprivate ReflectionMethod $originalMethodToCallfrom the__aop__alias orgetPrototype()and callsinvokeArgs($this->instance, $this->arguments)inproceed()— faster thanClosure::call()and correctly handles pass-by-reference argsStaticTraitAliasMethodInvocation— static-method invocation; wraps$closureToCallin astatic fn(array $args) => forward_static_call($callable, ...$args)shim and stores it in$closureToCall;bindTo(null, $scope)forwards the correct LSB class per call;proceed()calls($this->closureToCall)(...$this->arguments)ReflectionConstructorInvocation— constructor interception (used withINTERCEPT_INITIALIZATIONS); creates instance viaReflectionClass::newInstanceWithoutConstructor()then calls constructorReflectionFunctionInvocation— function interception; receives a first-class callable to the original global function (e.g.\strlen(...)with leading\to avoid recursive proxy call);proceed()calls($this->closureToCall)(...$this->arguments)directlyClassFieldAccess— property interception join point; used via generated native property hooks on proxied propertiesStaticInitializationJoinpoint— fired once after proxy class is loaded viainjectJoinPoints()
src/Aop/Pointcut/— LALR pointcut grammar (PointcutGrammar,PointcutParser,PointcutLexer,PointcutParseTable) and pointcut combinators (AndPointcut,OrPointcut,NotPointcut,NamePointcut,AttributePointcut, etc.)src/Lang/Attribute/— PHP 8 attributes for declaring aspects and advice:#[Aspect],#[Before],#[After],#[Around],#[AfterThrowing],#[Pointcut],#[DeclareError],#[DeclareParents]src/Aop/Features.php— bitmask enum for optional features (INTERCEPT_FUNCTIONS,INTERCEPT_INITIALIZATIONS,INTERCEPT_INCLUDES)
Container.php— DI container withadd()(by class-string or key),getService(),addLazyService()(Closure), and automatic tagging by interfaceAspectLoader/CachedAspectLoader— scan aspect classes for pointcut/advice attributes and produceAdvisorinstancesAttributeAspectLoaderExtension— handles PHP 8 attribute-based aspect definitionsAdviceMatcher— given a class reflector, returns the set of applicable advisors keyed by join point; scansIS_PUBLIC | IS_PROTECTED | IS_PRIVATEmethods (private methods from parent classes are excluded)
src/Bridge/Doctrine/MetadataLoadInterceptor.php — workaround for Doctrine ORM entity weaving (Doctrine loads metadata before the kernel can intercept classes).
The framework supports PHP 8.4+ and handles most modern PHP syntax transparently. The following constructs have documented limitations or are intentionally excluded:
Enums are supported by WeavingTransformer using the same trait-extraction approach as classes, with adjustments for enum constraints:
- The original enum body is converted to a trait (cases stripped, backed type removed,
enum→trait) - A proxy enum is generated that re-uses the trait, re-declares all cases, and adds per-method
static $__joinPointdispatch — usingEnumProxyGenerator - Enums cannot have properties (static or instance), so per-method
static $__joinPointvariables are used (same pattern asClassProxyGeneratorandTraitProxyGenerator) - Built-in enum methods (
cases,from,tryFrom) are never intercepted — they are synthesised by PHP and cannot be aliased via trait use - Built-in PHP enum interfaces (
UnitEnum,BackedEnum) are never listed in the proxy'simplementsclause — PHP applies them automatically, and listing them explicitly in a namespaced file resolves them asNs\UnitEnuminstead of the global\UnitEnum, causing a fatal error
When a readonly class is woven, the generated trait drops the readonly modifier (traits cannot be readonly in PHP). The proxy class preserves the readonly modifier. Method interception uses per-method function-scoped static $__joinPoint variables (not class properties), which are permitted in readonly classes. Properties from the original readonly class regain their implicit readonly status in the readonly proxy class. Readonly properties are excluded from access(...) property interception (they cannot have hooks).
When a method marked #[\Override] is intercepted (i.e., aliased as __aop__methodName in the proxy's trait-use block), PHP would copy the #[\Override] attribute to the alias. Since the alias name has no parent method to override, PHP would raise a fatal error. WeavingTransformer::convertClassToTrait() therefore strips #[\Override] from the method body in the generated trait for every intercepted method. The attribute is preserved on the proxy's override method, where it is valid (the proxy extends the same parent).
For intercepted class properties, the declaration is moved from the woven trait to the proxy class and emitted with native get/set hooks that dispatch through ClassFieldAccess. The woven trait has those intercepted property declarations neutralized to avoid property conflicts while preserving line numbers.
Readonly properties and properties that already define hooks are intentionally skipped for access(...) interception.
The woven file (the trait that replaces the original class/enum body) must preserve the original source line numbers. This is required for XDebug breakpoints to map correctly: a breakpoint placed at a method in the original source file must land on the same line number in the woven trait file, because that is the file XDebug steps through when executing the real method body.
WeavingTransformer achieves this via token-level surgery on the original source:
- For classes:
convertClassToTrait()replaces theclasskeyword and strips modifiers/extends/implements, but keeps all other tokens (including blank lines) in place. - For enums:
convertEnumToTrait()must replace removed tokens (case declarations, backed type, implements clause) with an equal number of newlines so that methods remain at their original line positions. Removing tokens without replacement shifts subsequent lines upward.
The proxy file (generated by ClassProxyGenerator/EnumProxyGenerator) is a thin dispatch wrapper and does not need to match original line numbers. Debuggers will step through the woven trait for the real method bodies.
Classes that implement \Go\Aop\Aspect are unconditionally skipped by WeavingTransformer. Aspects cannot weave themselves.
- Tests mirror the
src/structure undertests/Go/ - Functional/integration tests live in
tests/Go/Functional/ - Test fixtures (stub classes for weaving) live in
tests/Go/Stubs/andtests/Fixtures/project/src/(autoloaded asGo\Tests\TestProject\) - Snapshot fixtures for
WeavingTransformerTestlive intests/Go/Instrument/Transformer/_files/;*-woven.phpis the transformed source (class→trait),*-proxy.phpis the generated proxy - PHPUnit 13+, bootstrap is
vendor/autoload.php(no separate test bootstrap) - PHPStan level 10 is a mandatory gate — run
./vendor/bin/phpstan analyze --memory-limit=512Mbefore every commit