Skip to content
Merged
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
14 changes: 10 additions & 4 deletions .github/workflows/phpstan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,21 @@ on:

jobs:
build:
name: "PHPStan analysis - PHP8.4"
name: "PHPStan analysis - PHP${{ matrix.php-version }}"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version:
- "8.4"
- "8.5"
steps:
- name: "Checkout"
uses: actions/checkout@v7
- name: "Install PHP"
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
php-version: "${{ matrix.php-version }}"
ini-values: memory_limit=-1
tools: composer:v2
- name: "Cache dependencies"
Expand All @@ -28,8 +34,8 @@ jobs:
path: |
~/.composer/cache
vendor
key: "php-8.4"
restore-keys: "php-8.4"
key: "php-${{ matrix.php-version }}"
restore-keys: "php-${{ matrix.php-version }}"
- name: "Install dependencies"
run: "composer install --no-interaction --no-progress"
- name: "Static analysis"
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ if ($fieldAccess->getField()->isInitialized($this)) {
```

> **See also:** [PHP 8.4 Feature Support & Known Limitations](docs/php84-limitations.md) for detailed information about property hooks, readonly properties, lazy objects, and other PHP 8.4 features.
>
> [PHP 8.5 Feature Support & Known Limitations](docs/php85-limitations.md) covers the PHP 8.5 audit results.

### 🛠️ Developer Experience

Expand Down
110 changes: 110 additions & 0 deletions docs/php85-limitations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# PHP 8.5 Feature Support & Known Limitations

This document describes how Go! AOP Framework handles PHP 8.5 features in proxy generation and
interception, and what limitations exist. It is based on an audit of the framework on
PHP 8.5.10 and PHP 8.6.0beta2 (see PR #597).

Several of the limitations below are actively being worked on — they are phrased as
"tracked in #NNN" rather than permanent facts, and may already be fixed on master.

## What works

### Pipe operator `|>`

The [pipe operator](https://wiki.php.net/rfc/pipe-operator-v3) works inside woven method bodies.
Method bodies are moved verbatim into the `__AopProxied` trait, so any PHP 8.5 expression syntax
inside them is preserved.

### `clone with`

[`clone with`](https://wiki.php.net/rfc/clone_with_v2) expressions in woven code work as expected.

### `#[\NoDiscard]` attribute

The [`#[\NoDiscard]`](https://wiki.php.net/rfc/marking_return_value_as_important) attribute is
copied to the generated proxy method, and the join-point dispatch returns the intercepted
method's value, so the engine-level "return value not used" warning keeps firing correctly for
woven methods.

### Attributes on class constants

[Attributes on constants](https://wiki.php.net/rfc/attributes-on-constants) — including
`#[\Deprecated]` — are preserved: class constants stay in the woven trait together with their
attributes.

### Closures and first-class callables as parameter defaults

[Closures in constant expressions](https://wiki.php.net/rfc/closures_in_const_expr) used as
parameter default values survive weaving in proxy method signatures.

### Final promoted properties and static asymmetric visibility (non-intercepted)

`final` promoted constructor properties and `static` properties with asymmetric visibility
(`public static private(set)`) work on classes that are woven, as long as the properties
themselves are not targeted by `access(...)` pointcuts (see the static-property note below).

### `self`/`parent` reflection resolution

On PHP 8.5, `ReflectionNamedType::getName()` resolves `self`/`parent` return types to concrete
class names. The proxy generators compensate by reading the raw AST type node where available,
so `self`/`parent` keywords are preserved in generated proxies.

## Known limitations

### Closures / first-class callables in attribute arguments — tracked in [#601](https://github.com/goaop/framework/issues/601)

Attribute arguments containing closures or first-class callable syntax (allowed since PHP 8.5)
are not yet correctly copied to generated proxies.

### Promoted-property interception — tracked in [#599](https://github.com/goaop/framework/issues/599)

Constructor-promoted properties (including `final` promoted properties, new in PHP 8.5) cannot be
intercepted via `access(...)` pointcuts.

### Enum constant-expression case values — tracked in [#600](https://github.com/goaop/framework/issues/600)

Backed enum cases declared with constant expressions (e.g. `case Negative = -1;`,
`case Shifted = 1 << 2;`, `case FromConst = self::SHIFT + 10;`) previously lost their values in
the generated proxy enum, producing a fatal error at load time. Fixed by re-emitting the original
case expression verbatim in the proxy enum.

### `new` in initializers under `INTERCEPT_INITIALIZATIONS` — tracked in [#603](https://github.com/goaop/framework/issues/603)

`new` expressions in property/parameter initializers do not work correctly when the
`INTERCEPT_INITIALIZATIONS` kernel feature is enabled.

### Global constants in attribute arguments — tracked in [#602](https://github.com/goaop/framework/issues/602)

Unqualified global constants used in attribute arguments may be mis-resolved when the attribute
is copied into the generated proxy file.

### Class-level attributes on woven classes — tracked in [#598](https://github.com/goaop/framework/issues/598)

Class-level attributes are not correctly carried over to woven classes in all cases.

### Static properties are never interceptable

Static properties — including PHP 8.5 `static` properties with asymmetric visibility — cannot be
intercepted via `access(...)` pointcuts. PHP property hooks do not exist for static properties,
so the framework has no interception mechanism for them; `AdviceMatcher` excludes static
properties from property join points. This is a PHP engine constraint, not a bug.

## Summary Table

| PHP 8.5 Feature | Interception / Weaving Support | Notes |
|---|:---:|---|
| Pipe operator `\|>` in method bodies | ✅ Works | Bodies are moved verbatim into the woven trait |
| `clone with` | ✅ Works | |
| `#[\NoDiscard]` | ✅ Propagated | Copied to proxy; join-point dispatch returns the value |
| Attributes on class constants (incl. `#[\Deprecated]`) | ✅ Preserved | Constants stay in the woven trait |
| Closures / FCC as parameter defaults | ✅ Works | |
| Final promoted properties (non-intercepted) | ✅ Works | Interception of promoted properties tracked in [#599](https://github.com/goaop/framework/issues/599) |
| Static asymmetric visibility (non-intercepted) | ✅ Preserved | Static properties are never interceptable via `access()` |
| `self`/`parent` reflection resolution | ✅ Handled | Raw AST type nodes used in proxy generation |
| Closures / FCC in attribute arguments | ❌ Limited | Tracked in [#601](https://github.com/goaop/framework/issues/601) |
| Promoted-property interception | ❌ Limited | Tracked in [#599](https://github.com/goaop/framework/issues/599) |
| Enum constant-expression case values | ❌→✅ Fixed | Tracked in [#600](https://github.com/goaop/framework/issues/600) |
| `new` in initializers + `INTERCEPT_INITIALIZATIONS` | ❌ Limited | Tracked in [#603](https://github.com/goaop/framework/issues/603) |
| Global constants in attribute arguments | ❌ Limited | Tracked in [#602](https://github.com/goaop/framework/issues/602) |
| Class-level attributes on woven classes | ❌ Limited | Tracked in [#598](https://github.com/goaop/framework/issues/598) |
| Static property interception via `access()` | ❌ Never | No property hooks for static properties (PHP engine constraint) |
12 changes: 12 additions & 0 deletions src/Aop/Pointcut/ModifierPointcut.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@

/**
* ModifierPointcut performs matching on modifiers for reflector
*
* Matching is bitmask-based on {@see \ReflectionMethod::getModifiers()} /
* {@see \ReflectionProperty::getModifiers()}. Besides the classic visibility masks
* (public/protected/private/static/final), property-only masks are supported:
*
* - {@see \ReflectionProperty::IS_READONLY} — 'readonly' grammar predicate
* - {@see \ReflectionProperty::IS_PRIVATE_SET} — 'private(set)' grammar predicate (PHP 8.4+)
* - {@see \ReflectionProperty::IS_PROTECTED_SET} — 'protected(set)' grammar predicate (PHP 8.4+)
*
* Both native reflection and Go\ParserReflection\ReflectionProperty expose these bits via
* getModifiers(), so no reflection-implementation-specific guards are needed here. Methods
* never carry these bits, so such predicates simply never match method reflectors.
*/
final class ModifierPointcut implements Pointcut
{
Expand Down
3 changes: 1 addition & 2 deletions src/Aop/Pointcut/NamePointcut.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
/**
* Name matcher constructor
*
* @param string $name Element name to match, can contain wildcards **,*,?,|
* @param string $name Element name to match, can contain wildcards **,*,|
* @param bool $useContextForMatching Switch to matching context name instead of reflector
*/
public function __construct(
Expand All @@ -46,7 +46,6 @@ public function __construct(
[
'\\*' => '[^\\\\]+?',
'\\*\\*' => '.+?',
'\\?' => '.',
'\\|' => '|'
]
) . ')$/';
Expand Down
26 changes: 24 additions & 2 deletions src/Aop/Pointcut/PointcutGrammar.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Go\Aop\Pointcut;
use Go\Core\AspectContainer;
use ReflectionMethod;
use ReflectionProperty;
use function constant;

/**
Expand Down Expand Up @@ -185,7 +186,7 @@ function (ClassMemberReference $reference) {
);
}
)
->is('memberReference', '(', 'argumentList', ')', ':', 'namespaceName')
->is('memberReference', '(', 'argumentList', ')', ':', 'returnTypePattern')
->call(
function (ClassMemberReference $reference, mixed $_0, mixed $_1, mixed $_2, mixed $_3, string $returnType) {
return new AndPointcut(
Expand All @@ -211,7 +212,7 @@ function (string $namespacePattern, mixed $_0, string $namePattern) {
);
}
)
->is('namespacePattern', 'nsSeparator', 'namePatternPart', '(', 'argumentList', ')', ':', 'namespaceName')
->is('namespacePattern', 'nsSeparator', 'namePatternPart', '(', 'argumentList', ')', ':', 'returnTypePattern')
->call(
function (string $namespacePattern, mixed $_0, string $namePattern, mixed $_1, mixed $_2, mixed $_3, mixed $_4, string $returnType) {
return new AndPointcut(
Expand Down Expand Up @@ -304,6 +305,21 @@ function () {
->call($stringConverter)
;

// Return-type patterns support union ('|') and intersection ('&') members.
// DNF groups are written without parentheses — 'A&B|C' is equivalent to '(A&B)|C',
// matching PHP's own type precedence; ReturnTypePointcut normalizes both forms.
$this('returnTypeMember')
->is('namespaceName')
->is('returnTypeMember', '&', 'namespaceName')
->call(fn(string $left, mixed $_0, string $right) => "{$left}&{$right}")
;

$this('returnTypePattern')
->is('returnTypeMember')
->is('returnTypePattern', '|', 'returnTypeMember')
->call(fn(string $left, mixed $_0, string $right) => "{$left}|{$right}")
;

$this('memberModifiers')
->is('memberModifier', '|', 'memberModifiers')
->call(fn(int $modifier, mixed $_0, ModifierPointcut $matcher) => $matcher->orMatch($modifier))
Expand All @@ -323,6 +339,12 @@ function () {
->call($converter)
->is('final')
->call($converter)
->is('readonly')
->call(fn() => ReflectionProperty::IS_READONLY)
->is('private(set)')
->call(fn() => ReflectionProperty::IS_PRIVATE_SET)
->is('protected(set)')
->call(fn() => ReflectionProperty::IS_PROTECTED_SET)
;

$this->start('pointcutExpression');
Expand Down
6 changes: 6 additions & 0 deletions src/Aop/Pointcut/PointcutLexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ public function __construct()
$this->token('protected');
$this->token('private');
$this->token('final');
$this->token('readonly');

// Asymmetric visibility modifiers (PHP 8.4+), lexed as single tokens.
// The lexer prefers the longest match, so 'private(set)' wins over 'private' + '('.
$this->token('private(set)');
$this->token('protected(set)');

// Access type (dynamic or static)
$this->token('->');
Expand Down
Loading