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
20 changes: 20 additions & 0 deletions docs/plans/2026-10-14-aop-registration-seal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Close registration before provider boot

## Design

Aspect rules and class-map replacements determine generated proxies. Registration after generation can silently miss a target, leave a method unintercepted, or bypass an existing proxy. Enforce the existing provider `register()` contract without adding work to method dispatch.

- Give `AspectCollector` and `ClassMapManager` a protected static boolean seal, with public `seal()` and `unseal()` methods. Document these worker-wide mutators as boot/test operations. Do not introduce shared traits, testing detection, snapshots, or additional state machinery.
- Check the seal inline in `AspectCollector::setAround()` and `ClassMapManager::add()` before mutation. `register()` already delegates to `setAround()`; no duplicate guard is needed. Throw `InvalidDefinitionException` for aspects and the existing `RuntimeException` for replacements, directing callers to provider `register()`.
- Reopen both registries at the start of `RegisterProviders::bootstrap()` so reloads and refreshes within one test can register providers again. Preserve existing rules/replacements when reopening. Testbench inherits this bootstrapper.
- Seal both registries after successful `GenerateProxies::bootstrap()`, including when there are no aspects or no matching targets. Keep standalone `generate()` reusable and non-sealing. Do not recover failed partial bootstraps.
- Add `AspectManager::clear()` to discard compiled chains. Call it whenever aspect rules are registered, forgotten, or reset; `AspectManager::flushState()` delegates to it. Rule changes own invalidation, so adding/removing aspects between focused test calls cannot retain stale dispatch. `unseal()` only changes the flag. Dispatch and chain execution gain no checks or counters.
- `flushState()` restores each registry to its initial open state. Existing framework cleanup still owns static cleanup. The PHPUnit extension already resets aspect rules before restoring its saved registrations.
- The PHPUnit extension explicitly reopens aspect registration before preparing temporary proxy metadata. ParaTest's parent process has already terminated its setup applications before loading extensions; retaining their closed registration window serves no caller. Keep the existing `finally` rule restoration and open end state. Do not add a seal getter or state-restoration machinery.
- Preserve focused `callWithAspects()` tests after application bootstrap through explicit `AspectCollector::unseal()`. No automatic PHPUnit or Testbench bypass. Normal provider `boot()` registration must fail in tests too.
- When Testbench `refreshApplication()` fails while creating a replacement app, restore the previous test application's container and facade globals, clear resolved facade instances, and rethrow the original exception. Its failed-app cleanup already flushes the replacement; leaving globals pointed there breaks subsequent facade calls and teardown. Limit recovery to failed creation with an existing test application; keep successful route setup outside the catch.
- Update `aop.md`, the advanced-provider section of `providers.md`, and class-map guidance in `packages.md`. Explain the closed boundary and explicit focused-testing opt-in concisely. Remove stale no-op wording and touched comments. This fixes Hypervel-only contracts and needs no Laravel porting-guide entry.

## Status

Implementation, verification, and peer review are complete.
55 changes: 43 additions & 12 deletions src/di/src/Aop/AspectCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,12 @@

namespace Hypervel\Di\Aop;

use Hypervel\Di\Exceptions\InvalidDefinitionException;
use Hypervel\Support\ClassMetadataCache;
use ReflectionProperty;

/**
* Static registry of aspect class rules and priorities.
*
* Tracks which aspect classes target which classes/methods,
* and their execution priority. Used by ProxyManager to determine
* which classes need proxy generation and by ProxyDispatcher at runtime
* to resolve the aspect pipeline for each method call.
* Worker-wide aspect rules used to generate proxies and compile dispatch chains.
*/
class AspectCollector
{
Expand All @@ -24,10 +20,13 @@ class AspectCollector
*/
protected static array $aspectRules = [];

protected static bool $sealed = false;

/**
* Register an aspect using its public default targeting rules and priority.
*
* Boot-only. Register before proxy generation; loaded classes cannot be rewritten.
* Boot or tests only. Register before application proxy generation, or explicitly
* reopen registration for focused tests. Loaded classes cannot be rewritten.
*
* @param class-string $aspect
*/
Expand All @@ -53,25 +52,53 @@ public static function register(string $aspect): void
/**
* Register an aspect with its class targeting rules.
*
* Boot-only. Aspect rules persist in a static property used by the
* Boot or tests only. Aspect rules persist in a static property used by the
* compile-time proxy generator; runtime use cannot retroactively apply
* aspects to already-loaded classes.
*/
public static function setAround(string $aspect, array $classes, ?int $priority = null): void
{
if (static::$sealed) {
throw new InvalidDefinitionException(
"Cannot register aspect [{$aspect}] after proxy generation. "
. 'Register aspects in a service provider\'s register() method, before boot().'
);
}

$priority ??= 0;

// Merge idempotently: a provider re-registering the same aspect on a repeated
// boot in the same worker must not append duplicate class rules; the proxy
// generator scans every rule against the whole class map, so duplicates make
// each boot slower than the last.
// Repeated boots re-register aspects. Duplicate rules would change the shared
// fingerprint, regenerating every proxy, and wildcards would rescan the class map.
$existing = static::$aspectRules[$aspect]['classes'] ?? [];
$classes = array_values(array_unique(array_merge($existing, $classes)));

static::$aspectRules[$aspect] = [
'priority' => $priority,
'classes' => $classes,
];

AspectManager::clear();
}

/**
* Close aspect registration after application proxy generation.
*
* Boot-only. Later registrations in this worker are rejected.
*/
public static function seal(): void
{
static::$sealed = true;
}

/**
* Reopen aspect registration while retaining the registered rules.
*
* Boot or tests only. This permits changes to worker-wide rules for a new
* application bootstrap or focused tests; it does not rewrite loaded classes.
*/
public static function unseal(): void
{
static::$sealed = false;
}

/**
Expand All @@ -91,6 +118,8 @@ public static function hasAspects(): bool
public static function forgetAspect(string $aspect): void
{
unset(static::$aspectRules[$aspect]);

AspectManager::clear();
}

/**
Expand Down Expand Up @@ -143,5 +172,7 @@ public static function getRules(): array
public static function flushState(): void
{
static::$aspectRules = [];
static::unseal();
AspectManager::clear();
}
}
13 changes: 12 additions & 1 deletion src/di/src/Aop/AspectManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,22 @@ public static function set(string $class, string $method, Closure $value): void
static::$chains[$class][$method] = $value;
}

/**
* Clear the compiled aspect chains.
*
* Boot or tests only. Clearing this worker-wide cache makes subsequent
* calls compile their chains from the current aspect rules.
*/
public static function clear(): void
{
static::$chains = [];
}

/**
* Flush all static state.
*/
public static function flushState(): void
{
static::$chains = [];
static::clear();
}
}
7 changes: 5 additions & 2 deletions src/di/src/Bootstrap/GenerateProxies.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
*
* Runs after all service providers have been registered (so all
* aspects() calls have executed) and before boot() (so no targeted
* classes have been instantiated yet). No-ops when no aspects are
* registered.
* classes have been instantiated yet). Closes aspect and class map registration
* even when no proxies need to be generated.
*/
class GenerateProxies
{
Expand All @@ -47,6 +47,9 @@ public function bootstrap(ApplicationContract $app): void
if (AspectCollector::hasAspects()) {
$this->generate($app->bootstrapPath('cache/aop'));
}

AspectCollector::seal();
ClassMapManager::seal();
}

/**
Expand Down
33 changes: 32 additions & 1 deletion src/di/src/ClassMap/ClassMapManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,28 @@ class ClassMapManager

protected static ?ClassLoader $loader = null;

protected static bool $sealed = false;

/**
* Add class map entries and apply them to the Composer autoloader.
*
* Each entry maps an original class name to the path of its replacement file.
* Fails if a target is already loaded from a different source.
*
* Boot-only. Class-map overrides mutate the worker's Composer autoloader and
* must be registered before any target class is autoloaded.
* must be registered before application proxy generation and target loading.
*
* @param array<class-string, string> $map
*/
public static function add(array $map): void
{
if (static::$sealed) {
throw new RuntimeException(
'Cannot register class map overrides after proxy generation. '
. 'Register overrides in a service provider\'s register() method, before boot().'
);
}

foreach ($map as $class => $path) {
if (class_exists($class, false) || interface_exists($class, false) || trait_exists($class, false)) {
$reflection = new ReflectionClass($class);
Expand Down Expand Up @@ -67,6 +76,27 @@ public static function add(array $map): void
static::$loader->addClassMap($map);
}

/**
* Close class map registration after application proxy generation.
*
* Boot-only. Later registrations in this worker are rejected.
*/
public static function seal(): void
{
static::$sealed = true;
}

/**
* Reopen class map registration while retaining the registered overrides.
*
* Boot or tests only. This permits worker-wide autoloader changes for a new
* application bootstrap; already-loaded classes cannot be replaced.
*/
public static function unseal(): void
{
static::$sealed = false;
}

/**
* Publish generated replacements with the lifetime of their source overrides.
*
Expand Down Expand Up @@ -105,5 +135,6 @@ public static function flushState(): void
static::$loader?->unregister();
static::$loader = null;
static::$entries = [];
static::unseal();
}
}
7 changes: 5 additions & 2 deletions src/docs/aop.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@ $this->aspects(ProfileReports::class, TraceHttpRequests::class);
```

> [!WARNING]
> Aspects must be registered during `register`, before the target classes are loaded. Runtime registration cannot retroactively apply aspects to classes that have already been loaded or proxied.
> Aspects must be registered during `register`, before the target classes are loaded. Registration closes after the application's proxy generation step. Registering an aspect during `boot` or later throws an exception.

<a name="proxy-generation"></a>
## Proxy Generation
Expand All @@ -226,7 +226,7 @@ Hypervel generates AOP proxy classes automatically during application bootstrap.

Target classes must not be loaded before proxy generation. Bind factories in your providers' `register` methods and create their instances during `boot` or later. Hypervel reports an error if a target was loaded too early, or if an already-loaded proxy cannot apply the current method rules. Restart workers after changing aspect rules.

If no aspects have been registered, proxy generation does nothing. Existing proxies are reused only while their content fingerprint still matches the source path and contents, aspect rules, registered AST visitors, generator implementation, PHP parser, and PHP version. Changes to any of those inputs regenerate the affected proxy during the next application bootstrap.
If no aspects have been registered, no proxy files are generated, but registration still closes. Existing proxies are reused only while their content fingerprint still matches the source path and contents, aspect rules, registered AST visitors, generator implementation, PHP parser, and PHP version. Changes to any of those inputs regenerate the affected proxy during the next application bootstrap.

Each targeted source file must declare exactly one named class, interface, trait, or enum. Nested anonymous classes are supported, but multiple named class-like declarations should be split into separate files before applying an aspect. Methods that return by reference cannot be intercepted because the aspect pipeline returns values rather than reference identities.

Expand Down Expand Up @@ -268,6 +268,7 @@ class ProfileReportsTest extends TestCase

public function test_it_runs_the_aspect(): void
{
AspectCollector::unseal();
AspectCollector::setAround(ProfileReports::class, [
ReportService::class . '::generate',
]);
Expand All @@ -283,4 +284,6 @@ class ProfileReportsTest extends TestCase
}
```

The `unseal` method reopens registration for this test while keeping the application's existing aspects. It does not change classes that have already been loaded, so only use it in tests. Aspects registered by a test are removed before the next test runs.

The `callWithAspects` method expects an object that has not already been proxied. If the object is already proxied, call the method normally instead.
2 changes: 1 addition & 1 deletion src/docs/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ public function register(): void

The replacement file should define the original class name. In the example above, Composer is still being asked to load `VendorPackage\Client`; Hypervel simply tells Composer to load that class from your replacement file instead of the dependency's original file. The replacement should remain compatible with the original class's public API, since other code will still interact with it as `VendorPackage\Client`.

Class map overrides take effect immediately and must be registered before the target class, interface, or trait has been loaded. Register them in the service provider's `register` method before resolving services or referencing classes that may load the target. Repeating an override for the same source file is safe, including when Testbench boots another application. Attempting to replace a loaded target with a different source file throws an exception.
Class map overrides take effect immediately and must be registered before the target class, interface, or trait has been loaded. Register them in the service provider's `register` method before resolving services or referencing classes that may load the target. Registration closes after proxy generation; registering an override during `boot` or later throws an exception. Repeating an override for the same source file during registration is safe, including when Testbench boots another application. Attempting to replace a loaded target with a different source file throws an exception.

> [!WARNING]
> Class map overrides affect the worker's autoloader for the lifetime of the process. They should only be used for boot-time package integration, not request-specific behavior.
Expand Down
2 changes: 1 addition & 1 deletion src/docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ When this method returns `false`, the provider's `register` and `boot` methods w
<a name="advanced-provider-apis"></a>
### Advanced Provider APIs

Package service providers may also register AOP aspects and class map overrides. These methods should be called within the `register` method before the target classes are loaded:
Package service providers may also register AOP aspects and class map overrides. Call these methods within `register`, before the target classes are loaded. Registration closes after proxy generation, so calls during `boot` or later throw an exception:

```php
use App\Aspects\TraceRequests;
Expand Down
6 changes: 6 additions & 0 deletions src/foundation/src/Bootstrap/RegisterProviders.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Hypervel\Foundation\Bootstrap;

use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\ClassMap\ClassMapManager;

class RegisterProviders
{
Expand All @@ -25,6 +27,10 @@ class RegisterProviders
*/
public function bootstrap(ApplicationContract $app): void
{
// Each application opens a registration window; GenerateProxies closes it before provider boot.
AspectCollector::unseal();
ClassMapManager::unseal();

if (! $app->bound('config_loaded_from_cache')
|| $app->make('config_loaded_from_cache') === false) {
$this->mergeAdditionalProviders($app);
Expand Down
4 changes: 4 additions & 0 deletions src/foundation/src/Testing/Concerns/InteractsWithAop.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Hypervel\Foundation\Testing\Concerns;

use Hypervel\Di\Aop\AspectCollector;
use Hypervel\Di\Aop\ProxyDispatcher;
use Hypervel\Di\Aop\ProxyMarker;
use LogicException;
Expand Down Expand Up @@ -43,6 +44,9 @@ protected function isAopProxied(object $instance): bool
* The original method is called via reflection at the end of the pipeline.
* The instance must not already be proxied.
*
* After application bootstrap, call {@see AspectCollector::unseal()} before
* registering aspects for this helper.
*
* @param object $instance The object instance to call the method on
* @param string $method The method name to intercept
* @param array $arguments Method arguments as key => value pairs (parameter names as keys)
Expand Down
20 changes: 19 additions & 1 deletion src/testbench/src/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@
namespace Hypervel\Testbench;

use Closure;
use Hypervel\Container\Container;
use Hypervel\Coordinator\Constants;
use Hypervel\Coordinator\CoordinatorManager;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Foundation\Application;
use Hypervel\Foundation\Testing\DatabaseMigrations;
use Hypervel\Foundation\Testing\DatabaseTransactions;
use Hypervel\Foundation\Testing\DatabaseTruncation;
use Hypervel\Foundation\Testing\RefreshDatabase;
use Hypervel\Foundation\Testing\TestCase as BaseTestCase;
use Hypervel\Support\Facades\Facade;
use Hypervel\Testbench\Attributes\ResetRefreshDatabaseState;
use Hypervel\Testbench\Attributes\WithMigration;
use ReflectionMethod;
Expand Down Expand Up @@ -189,7 +192,22 @@ protected function prepareDatabaseTruncationForMethod(array $uses): void
*/
protected function refreshApplication(): void
{
$this->app = $this->createApplication();
try {
$this->app = $this->createApplication();
} catch (Throwable $exception) {
// Failed creation flushes the replacement. Restore the previous container
// and facades so framework teardown can run.
if ($this->app !== null) {
/** @var Application $application */
$application = $this->app;

Container::setInstance($application);
Facade::clearResolvedInstances();
Facade::setFacadeApplication($application);
Comment on lines +204 to +206

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Model queries fail after recovery

When a provider throws during boot(), Testbench has already set Model's connection resolver to the replacement application's db service. Cleanup then flushes that application. This catch restores the container and facades, but model queries still use the flushed replacement and fail after the test catches the refresh error.

Save and restore the previous model resolver when recovering the application, and add a model query to the regression test.

Knowledge Base Used:

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observation is reproducible, but restoring Eloquent's resolver is beyond the recovery provided here. A failed refresh rethrows the bootstrap exception. The previous container and facades are restored so framework teardown can run; the previous application is not guaranteed to support further application work after the failure.

Restoring Eloquent alone would not provide that guarantee: provider registration also replaces the request user resolver and pagination view resolver with callbacks that capture the replacement application. Rolling back individual globals would leave an incomplete recovery contract.

The existing teardown infrastructure uses the retained application and restored facades, and framework static state is reset between tests. We are therefore keeping the existing behavior. Commit 1ada627 clarifies the teardown purpose in the source comment and regression-test name. The test passes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That distinction is valid. The recovery path is specifically to restore the retained application as the owner of the container and facades so teardown can complete, not to guarantee that the previous application remains usable after a failed refresh. Since restoring only Eloquent’s resolver would provide an incomplete rollback while other callbacks still capture the replacement, I’m withdrawing the finding. The clarified comment and regression test make the intended teardown-only contract clear.

}

throw $exception;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Setup routes after application is created (providers are booted).
$this->setUpApplicationRoutes($this->app);
Expand Down
3 changes: 3 additions & 0 deletions src/testing/src/PHPUnit/AfterEachTestExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ protected function generateGuzzleProxies(): void
$directory = ParallelTesting::tempDir('guzzle-proxies');

try {
// ParaTest boots and terminates process-setup applications before loading extensions.
AspectCollector::unseal();

foreach ([
PromiseConstructionAspect::class,
PromiseOperationAspect::class,
Expand Down
Loading
Loading