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
4 changes: 3 additions & 1 deletion docs/php84-limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ Asymmetric visibility on **non-readonly** properties _is_ preserved in generated

## Lazy Objects

PHP 8.4 introduced `ReflectionClass::newLazyProxy()` and `ReflectionClass::newLazyGhost()` for [lazy object initialization](https://wiki.php.net/rfc/lazy-objects). While the framework's `Container` uses lazy objects internally, **lazy object initialization itself is not interceptable** by the framework. There is no join point for the moment a lazy proxy materializes its backing instance.
PHP 8.4 introduced `ReflectionClass::newLazyProxy()` and `ReflectionClass::newLazyGhost()` for [lazy object initialization](https://wiki.php.net/rfc/lazy-objects). The framework's `Container` uses native lazy proxies for its own deferred services and for aspects registered by class name: retrieving such a service hands out a typed, `instanceof`-correct `newLazyProxy()` instance, and the registered factory only runs on the first real interaction with that object (classes PHP cannot make lazy — internal classes and their non-`stdClass` subclasses, abstract classes, enums, and readonly classes before PHP 8.5 — fall back to eager construction).

However, **lazy object initialization itself is not interceptable** by the framework. There is no join point for the moment a lazy proxy materializes its backing instance.

## Summary Table

Expand Down
11 changes: 4 additions & 7 deletions src/Aop/Pointcut/AndPointcut.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,10 @@ public function matches(
ReflectionClass|ReflectionFileNamespace $context,
ReflectionMethod|ReflectionProperty|ReflectionFunction|null $reflector = null
): bool {
foreach ($this->pointcuts as $singlePointcut) {
if (!$singlePointcut->matches($context, $reflector)) {
return false;
}
}

return true;
return array_all(
$this->pointcuts,
fn(Pointcut $singlePointcut): bool => $singlePointcut->matches($context, $reflector)
);
}

public function getKind(): int
Expand Down
11 changes: 4 additions & 7 deletions src/Aop/Pointcut/OrPointcut.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,10 @@ public function matches(
ReflectionClass|ReflectionFileNamespace $context,
ReflectionMethod|ReflectionProperty|ReflectionFunction|null $reflector = null
): bool {
foreach ($this->pointcuts as $singlePointcut) {
if ($singlePointcut->matches($context, $reflector)) {
return true;
}
}

return false;
return array_any(
$this->pointcuts,
fn(Pointcut $singlePointcut): bool => $singlePointcut->matches($context, $reflector)
);
}

public function getKind(): int
Expand Down
2 changes: 1 addition & 1 deletion src/Core/AdviceMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ public function getAdvicesForClass(ReflectionClass $class, array $advisors): arr
$parentClass = $class->getParentClass();

$originalClass = $class;
if ($parentClass && strpos($parentClass->name, AspectContainer::AOP_PROXIED_SUFFIX) !== false) {
if ($parentClass && str_contains($parentClass->name, AspectContainer::AOP_PROXIED_SUFFIX)) {
$originalClass = $parentClass;
}

Expand Down
16 changes: 10 additions & 6 deletions src/Core/AspectContainer.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ interface AspectContainer
/**
* Returns a service from the container.
*
* Services registered via addLazyService() are constructed by their factory on first retrieval.
* Services registered via addLazyService() are returned as typed, instanceof-correct
* native lazy objects where possible; their factory runs on first actual use.
*
* @param class-string<T> $className Class-name of service to retrieve from the container
* @return T
Expand Down Expand Up @@ -113,10 +114,11 @@ public function has(string $id): bool;
* Passing an aspect instance registers it immediately, exactly as before.
*
* Passing a class-name defers construction until the aspect is first needed (first
* advice hit, or aspect enumeration during weaving), keeping it off the hot boot path.
* advice hit, or first real interaction with the lazy object handed out during aspect
* enumeration on the weaving path), keeping it off the hot boot path.
* An aspect with required constructor arguments must also pass a factory closure that
* creates the instance; without a factory the class must be default-constructible,
* which is validated when the aspect materializes.
* which is validated when the aspect materializes into its lazy object.
*
* @param Aspect|class-string<Aspect> $aspectOrClassName Aspect instance or its class-name
* @param null|Closure(AspectContainer $container): Aspect $aspectFactory Factory for deferred
Expand All @@ -142,9 +144,11 @@ public function add(string $id, mixed $value): void;
/**
* Adds a deferred service definition to the container.
*
* Nothing is autoloaded or constructed at registration time: the factory closure is
* invoked once, on the first retrieval of the service (or when the service matches a
* getServicesByInterface() query), and the result is stored as a regular container entry.
* Nothing is autoloaded or constructed at registration time. On the first retrieval of
* the service (or when the service matches a getServicesByInterface() query) the entry
* materializes as a native lazy object of the service class, and the factory closure is
* invoked once, on the first actual interaction with that object. For classes that PHP
* cannot make lazy the factory is invoked at materialization time instead.
*
* @param class-string<T> $id Identifier of value to store, must be equal to the class-name
* @param Closure(AspectContainer $container): T $lazyInitializationClosure
Expand Down
22 changes: 7 additions & 15 deletions src/Core/CachedAspectLoader.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@

namespace Go\Core;

use AllowDynamicProperties;
use RuntimeException;
use Go\Aop\Advisor;
use Go\Aop\Aspect;
use Go\Aop\Features;
Expand All @@ -23,12 +21,17 @@
/**
* Cached loader is responsible for faster initialization of pointcuts/advisors for concrete aspect
*
* @property AspectLoader $loader
* @phpstan-import-type KernelOptions from AspectKernel
*/
#[AllowDynamicProperties]
class CachedAspectLoader extends AspectLoader
{
/**
* Original loader, resolved from the container on first access and memoized in the backing store
*/
private AspectLoader $loader {
get => $this->loader ??= $this->container->getService($this->loaderId);
}

/**
* Path to the cache directory
*/
Expand Down Expand Up @@ -99,17 +102,6 @@ public function load(Aspect $aspect): array
return $loadedItems;
}

public function __get(string $name): AspectLoader
{
if ($name === 'loader') {
$this->loader = $this->container->getService($this->loaderId);

return $this->loader;
}
throw new RuntimeException('Not implemented');
}


/**
* Loads pointcuts and advisors from the file
*
Expand Down
146 changes: 126 additions & 20 deletions src/Core/Container.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class Container implements AspectContainer
*/
private array $factories = [];

/**
* @var array<class-string, Closure(): void> Optional eager validators for deferred services, run when the
* lazy object is created (first retrieval) - before the factory itself runs (first actual use)
*/
private array $factoryValidators = [];

/**
* @var array<class-string, list<string>> Holds information about mapping of interface tags into identifiers
*/
Expand Down Expand Up @@ -113,12 +119,20 @@ final public function registerAspect(Aspect|string $aspectOrClassName, ?Closure
}

// Deferred registration by class-name: the aspect is constructed on first use
// (first advice hit, or aspect enumeration on the weaving path), so a hot-cache
// request never pays for aspects it does not touch.
// (first advice hit, or first real interaction with the lazy object handed out
// on the weaving path), so a hot-cache request never pays for aspects it does
// not touch.
$this->addLazyService($aspectOrClassName, function () use ($aspectOrClassName, $aspectFactory): Aspect {
return $this->materializeAspect($aspectOrClassName, $aspectFactory);
});

// Cheap aspect declaration checks (implements Aspect, constructibility) run as soon
// as the service materializes into a lazy object, so misconfiguration surfaces on
// retrieval - construction itself stays deferred until first actual use.
$this->factoryValidators[$aspectOrClassName] = function () use ($aspectOrClassName, $aspectFactory): void {
$this->validateAspectRegistration($aspectOrClassName, $aspectFactory);
};

// In debug mode the aspect's source file must be tracked as a resource right away:
// SourceTransformingLoader consults resource freshness before any aspect materializes.
// Production skips this - its warm path never checks freshness, and a cache miss
Expand All @@ -142,9 +156,9 @@ final public function registerAspect(Aspect|string $aspectOrClassName, ?Closure
*/
private function materializeAspect(string $aspectClassName, ?Closure $aspectFactory): Aspect
{
if (!is_subclass_of($aspectClassName, Aspect::class)) {
throw new AspectException("Aspect class $aspectClassName must implement " . Aspect::class);
}
$this->validateAspectRegistration($aspectClassName, $aspectFactory);
assert(is_subclass_of($aspectClassName, Aspect::class));

if ($aspectFactory !== null) {
$aspect = $aspectFactory($this);
if (!$aspect instanceof $aspectClassName) {
Expand All @@ -154,17 +168,32 @@ private function materializeAspect(string $aspectClassName, ?Closure $aspectFact
return $aspect;
}

$constructor = (new ReflectionClass($aspectClassName))->getConstructor();
if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
throw new AspectException(
"Aspect $aspectClassName has required constructor arguments, "
. "pass a factory closure to registerAspect() to create it"
);
}

return new $aspectClassName();
}

/**
* Validates a deferred aspect registration without constructing the aspect
*
* @param null|Closure(AspectContainer): Aspect $aspectFactory
*
* @throws AspectException if the class is not an aspect or cannot be default-constructed
*/
private function validateAspectRegistration(string $aspectClassName, ?Closure $aspectFactory): void
{
if (!is_subclass_of($aspectClassName, Aspect::class)) {
throw new AspectException("Aspect class $aspectClassName must implement " . Aspect::class);
}
if ($aspectFactory === null) {
$constructor = (new ReflectionClass($aspectClassName))->getConstructor();
if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
throw new AspectException(
"Aspect $aspectClassName has required constructor arguments, "
. "pass a factory closure to registerAspect() to create it"
);
}
}
}

/**
* Whether the kernel that owns this container runs in debug mode
*/
Expand Down Expand Up @@ -203,6 +232,7 @@ final public function addLazyService(string $id, Closure $lazyInitializationClos
throw new AspectException("Lazy service id must be a valid class name, \"$id\" given");
}
$this->factories[$id] = $lazyInitializationClosure;
unset($this->factoryValidators[$id]);
}

final public function getService(string $className): object
Expand Down Expand Up @@ -240,10 +270,10 @@ final public function has(string $id): bool

final public function getServicesByInterface(string $interfaceTagClassName): array
{
// Deferred services are only tagged once constructed, so materialize the
// pending ones that implement the requested interface first. This path is
// only taken during weaving/console runs, never on a hot request.
// Both the is_subclass_of() autoload and the factory invocation can re-enter
// Deferred services are only tagged once materialized (as lazy objects), so
// materialize the pending ones that implement the requested interface first.
// This path is only taken during weaving/console runs, never on a hot request.
// The is_subclass_of() autoload (and an eager fallback factory) can re-enter
// this method (an aspect class autoloaded here goes through the weaving
// pipeline, which enumerates aspects again), consuming pending factories from
// under this loop - hence the existence re-check and the tolerant materialization.
Expand All @@ -262,7 +292,14 @@ final public function getServicesByInterface(string $interfaceTagClassName): arr
}

/**
* Constructs a deferred service from its registered factory and stores it in the container
* Materializes a deferred service into a container entry and tags it by its interfaces.
*
* Where the class supports it, the entry becomes a native lazy proxy
* ({@see ReflectionClass::newLazyProxy()}): a typed, instanceof-correct instance of the
* service class whose factory only runs on first actual interaction with the object.
* Classes that PHP cannot make lazy (internal classes and their non-stdClass subclasses,
* abstract classes, enums, readonly classes before PHP 8.5) and ids that are not loadable
* classes fall back to invoking the factory eagerly, as before.
*/
private function materializeService(string $id): void
{
Expand All @@ -271,9 +308,78 @@ private function materializeService(string $id): void
// Already materialized by a re-entrant call (e.g. triggered through autoloading)
return;
}
// Unset before invoking the factory so a re-entrant lookup cannot run it twice
// Unset before touching the class: autoloading (class_exists/reflection below) or an
// eager fallback factory can re-enter the container and must not materialize $id twice
unset($this->factories[$id]);
$this->add($id, $factory($this));

$validator = $this->factoryValidators[$id] ?? null;
unset($this->factoryValidators[$id]);
$validator?->__invoke();

$this->add($id, $this->createLazyService($id, $factory));
}

/**
* Creates the container entry for a deferred service: a native lazy proxy when possible,
* otherwise the eagerly invoked factory result
*
* @param Closure(AspectContainer): object $factory
*/
private function createLazyService(string $id, Closure $factory): object
{
if (!class_exists($id)) {
return $factory($this);
}
$reflection = new ReflectionClass($id);
if (!self::isLazyProxyCompatible($reflection)) {
return $factory($this);
}

return $reflection->newLazyProxy(function () use ($id, $factory): object {
$instance = $factory($this);
if (!$instance instanceof $id) {
throw new AspectException("Service $id is not properly registered");
}

return $instance;
});
}

/**
* Whether PHP can create a native lazy proxy for the given class
*
* @param ReflectionClass<object> $reflection
*/
private static function isLazyProxyCompatible(ReflectionClass $reflection): bool
{
if ($reflection->isInternal() || $reflection->isAbstract() || $reflection->isEnum()) {
return false;
}
// Lazy objects for readonly classes are only supported since PHP 8.5
if (PHP_VERSION_ID < 80500 && $reflection->isReadOnly()) {
return false;
}
// PHP creates lazy objects of classes without instance properties as already
// initialized, so the initializer (and with it the service factory) would never
// run - such services keep the eager construction path
$hasInstanceProperties = false;
foreach ($reflection->getProperties() as $property) {
if (!$property->isStatic() && !$property->isVirtual()) {
$hasInstanceProperties = true;
break;
}
}
if (!$hasInstanceProperties) {
return false;
}
// Subclasses of internal classes (other than stdClass) cannot be lazy
for ($parent = $reflection->getParentClass(); $parent !== false; $parent = $parent->getParentClass()) {
if ($parent->isInternal()) {
return $parent->getName() === 'stdClass';
}
}

return true;
}

final public function hasAnyResourceChangedSince(int $timestamp): bool
Expand Down
Loading