rfc:lazy-objects

PHP RFC: Lazy Objects

Introduction

Transparent lazy-initialization of objects is an important part of many PHP applications. However, achieving this kind of laziness in userland is complex, limited, and can have a significant performance impact. This proposal aims to bring lazy initialization to the PHP engine to mitigate these drawbacks.

Martin Fowler identifies four strategies to implement lazy loading using OOP: lazy initialization, value holders, virtual proxies (hereafter referred to as just “proxies”), and ghost objects. This RFC focuses on proxies and ghost objects, which provide transparent lazy-loading. Unlike lazy initialization and value holders, proxies and ghost objects do not require a class to be written with the lazy-loading concept in mind. Instead, the lazy-loading behavior can be attached externally.

In both strategies, we start with empty objects typically created with ReflectionClass::newInstanceWithoutConstructor(), attaching an initializer or factory that is called automatically when these objects are used. From an abstraction point of view, lazy objects from this RFC are indistinguishable from non-lazy ones: they can be used without knowing they are lazy. This is a core design principle of this RFC.

Lazy-loading of objects in PHP is already used in business-critical situations. For example, Symfony uses them in its dependency injection component to provide lazy services that are fully initialized only if needed. The Doctrine ORM makes its entities lazy, allowing objects to hydrate themselves from the database only when accessed. Other use cases include e.g. a JSON parser that uses lazy objects to defer parsing unless those objects are accessed.

Implementing proxies and ghost objects in userland is non-trivial. This has been explored in the ocramius/proxy-manager library and later in the symfony/var-exporter one. Current implementations have several limitations, including incompatibility with final classes, and performance overhead due to magic methods. This RFC proposes to implement ghost objects and state-proxies natively in the engine to address these issues.

Unless specified otherwise, this RFC will refer to “virtual proxies” and “state-proxies” as just “proxies”.

Implementation

Lazy objects are standard zend_object whose initialization is deferred until one of their properties is accessed. This is implemented using the same fallback mechanism as __get and __set magic methods, triggered when an uninitialized property is accessed. No performance overhead is thus added to non-lazy use cases. Execution of methods or property hooks does not trigger initialization until one of them accesses a backed property.

A lazy object can be created via the Reflection API, with the user specifying an initializer function that is called when initialization is required.

There are two kinds of lazy objects:

  • Ghost: These are initialized in-place by the initializer function.
  • Proxies: The initializer returns a new instance, and interactions with the proxy object are forwarded to this instance.

Internal objects are not supported because their state is usually not managed via regular properties.

It should be noted that the proposed Reflection API has been tested successfully on the Doctrine and on the Symfony projects, allowing to remove a bunch of hard-to-maintain code while improving transparency of lazy objects and keeping the test suite green with the same public API.

Proposal

This RFC proposes adding the following members to the ReflectionClass and ReflectionProperty classes:

class ReflectionClass
{
    public int const SKIP_INITIALIZATION_ON_SERIALIZE = 1;
    public int const SKIP_DESTRUCTOR = 2;
    public int const SKIP_INITIALIZED_READONLY = 4;
    public int const RESET_INITIALIZED_READONLY = 8;
 
    public function newLazyGhost(callable $initializer, int $options = 0): object {}
 
    public function newLazyProxy(callable $factory, int $options = 0): object {}
 
    public function resetAsLazyGhost(object $object, callable $initializer, int $options = 0): void {}
 
    public function resetAsLazyProxy(object $object, callable $factory, int $options = 0): void {}
 
    public function initialize(object $object, bool $skipInitializer = false): object {}
 
    public function isInitialized(object $object): bool {}
 
    // existing methods
}
 
class ReflectionProperty
{
    public function setRawValueWithoutLazyInitialization(object $object, mixed $value): void {}
 
    public function skipLazyInitialization(object $object): void {}
 
    // existing methods
}

Creating a Lazy Object

The entry points to create a lazy object are the ReflectionClass::newLazyGhost() and newLazyProxy() methods.

class MyClass
{
    public function __construct(private int $foo)
    {
        // Heavy initialization logic here.
    }
 
    // ...
}
 
$initializer = static function (MyClass $ghost): void {
    $ghost->__construct(123);
};
 
$reflector = new ReflectionClass(MyClass::class);
$object = $reflector->newLazyGhost($initializer);
 
// At this point, $object is a lazy ghost object.

Creating a lazy proxy requires using the newLazyProxy() method:

$initializer = static function (MyClass $proxy): MyClass {
    return new MyClass(123);
};
 
$reflector = new ReflectionClass(MyClass::class);
$object = $reflector->newLazyProxy($initializer);

The resetAsLazy*() methods accept an already created instance. This allows writing classes that manage their own laziness:

class MyLazyClass
{
    public function __construct()
    {
        $reflector = new ReflectionClass(self::class);
        $reflector->resetAsLazyGhost($this, $this->initialize(...), ReflectionClass::SKIP_DESTRUCTOR);
    }
 
    // ...
}

The behavior of these methods is described in more details later.

Handling the State of Lazy Objects

Any access to properties of a lazy object triggers its initialization (including via ReflectionProperty). However, certain properties might be known ahead of time and should not trigger initialization when accessed:

class BlogPost
{
    public function __construct(private int $id, private string $title, private string $content)
    {
    }
}
 
$reflector = new ReflectionClass(BlogPost::class);
$initializer = // Callable that retrieves the title and content from the database.
$post = $reflector->newLazyGhost($initializer);
 
// Without this line, the following call to ReflectionProperty::setValue() would trigger initialization.
$reflector->getProperty('id')->skipLazyInitialization($post);
$reflector->getProperty('id')->setValue($post, 123);
 
// Alternatively, one can use this directly:
$reflector->getProperty('id')->setRawValueWithoutLazyInitialization($post, 123);

The skipLazyInitialization() / setRawValueWithoutLazyInitialization() methods provide ways to disable lazy-initialization when a property is accessed, allowing users to choose the approach that best fits their design.

Lifecycle of Lazy Objects

An object is considered lazy if any of its properties are still hooked to the initializer passed to the newLazy*() and resetAsLazy*() methods that made it lazy.

There are three ways to make a lazy object non-lazy:

  1. Interacting with it in a way that triggers its initializer (more on this bellow).
  2. Using ReflectionProperty::skipLazyInitialization() or setRawValueWithoutLazyInitialization() on all its properties.
  3. Calling the ReflectionClass::initialize() method with the instance as argument.

The initialize() method's bool $skipInitializer argument (default false) allows marking a lazy object as non-lazy without running the initializer, leaving uninitialized properties. This is useful for managed entity objects. Accessing a property before it is set throws an “uninitialized property” error.

Initialization Triggers

Except for the special cases listed below, any attempt to observe the state of a lazy object will trigger its initialization. This ensures that the result of the observation is the same as if the object were already initialized, maintaining full transparency. These triggers include:

  • Reading or writing a property
  • Testing if a property is set or unsetting it
  • Calling ReflectionProperty::getValue() and setValue()
  • Calling ReflectionObject::getProperties()

This behavior makes lazy objects fully transparent to their consumers.

The following special cases do not trigger initialization of a lazy object:

  • Calls to ReflectionProperty::skipLazyInitialization(), setRawValueWithoutLazyInitialization(), or accesses to properties on which these methods were called.
  • Calls to get_object_vars() and get_mangled_object_vars().
  • Calls to serialize() when ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE is set, unless a property is accessed in __serialize() or __sleep() methods.
  • Calls to ReflectionObject::__toString().
  • Casting to array using the (array) operator.
  • Calls to var_dump($lazyObject), unless __debugInfo() is implemented and accesses a property.
  • Cloning, unless __clone() is implemented and accesses a property.

By excluding these cases from triggering initialization, developers can perform certain operations on lazy objects without causing them to initialize, providing finer control over the initialization process.

Initialization Sequence

Ghost Objects

  1. Before calling the initializer:
    1. Properties that were not initialized with ReflectionProperty::skipLazyInitialization() or setRawValueWithoutLazyInitialization() are initialized to their default value if any, in the same way as using ReflectionClass::newInstanceWithoutConstructor().
    2. The object is marked as non-lazy.
  2. During initialization, properties can be accessed directly without triggering recursive initialization. Accessing properties without a default value may throw an error, as usual.
  3. The initializer must return null or no value

After initialization, the object is indistinguishable from an object that was never lazy.

Proxy Objects

  1. Before calling the initializer, the object is marked as non-lazy.
  2. The initializer is called with the lazy proxy as first parameter.
  3. The return value of the initializer has to be a non-lazy instance of a parent or a child class of the lazy-object and it must have the same properties. An Error is thrown if that's not the case. Returning an instance of a child or parent class with the same properties does not break LSP.
  4. The actual instance is set to the return value.
  5. Properties are uninitialized (including properties used with ReflectionProperty::skipLazyInitialization() or setRawValueWithoutLazyInitialization()).

The proxy object is _not_ replaced or substituted for the actual instance. After initialization, property accesses on the proxy are forwarded to the actual instance. Observing properties of the proxy has the same result as observing properties of the actual instance.

The actual instance is allowed to escape the proxy and to create direct references to itself. This is demonstrated in the section named “About Proxies”. The proxy may be released independently of the actual instance when it's not referenced anymore. The proxy and actual instance have distinct identities.

Although the initializer receives the proxy object as first parameter, it is not expected to make changes to it (this is allowed, but any changes will be lost during the last step of the initialization). However, the proxy object can be used to make decisions based on the value of some initialized field, or on the class or the object, or on its identity. For example, we can use the object identity to detect clones:

$init = function ($object) use (&$originalObject) {
    if ($object !== $originalObject) {
        // we are initializing a clone
    }
};
$originalObject = $reflector->newLazyProxy($init);

Common Behavior

After a successful initialization, the initializer function is not retained anymore by this object, and may be released if it's not referenced anywhere else.

The scope and $this of the initializer function is not changed, and usual visibility constraints apply. Visibility should not be a concern for the common use-case of calling the constructor or another public method in the initializer. However, for more complex use-cases where the initializer wishes to access non-public properties, it is required to bind the initializer function to the right scope (with Closure::bind()), or to access properties with ReflectionProperty.

class MyClass {
    private $prop;
    public function __construct($prop) {
        $this->prop = $prop;
    }
}
 
$reflector = new ReflectionClass(MyClass::class);
 
// Common use-case
$object = $reflector->newLazyGhost(function ($object) {
    $object->__construct('value'); // Ok
});
 
// Complex use-case
$object = $reflector->newLazyGhost(function ($object) use ($reflector) {
    $object->prop = 'value';                    // Error: Cannot access private property MyClass::$prop
    $propReflector = $reflector->getProperty('prop');
    $propReflector->setValue($object, 'value'); // Ok
});

More real-world examples can be seen in the “Lazy-Loading Strategies” section.

If the initializer throws, the object properties are reverted to their pre-initialization state and the object is marked as lazy again. In other words, all effects on the object itself are reverted. Other side effects, such as side-effects on other objects, are not reverted. The goal is to not expose a half-initialized instance in case of failure.

The following example demonstrates what happens when nested initialization fail:

class MyClass {
    public $propA;
    public $propB;
}
 
// Creating two lazy objects. The initializer of $object1 causes the initialization
// of $object2, which fails.
 
$reflector = new ReflectionClass(MyClass::class);
 
$object2 = $reflector->newLazyGhost(function ($object2) {
    $object2->propB = 'value';
    throw new \Exception('initializer exception');
});
$reflector->getProperty('propA')->setRawValueWithoutLazyInitialization($object2, 'object-2');
 
$object1 = $reflector->newLazyGhost(function ($object1) use ($object2) {
    $object1->propB = 'updated';
    $object1->propB = $object2->propB;
});
$reflector->getProperty('propA')->setRawValueWithoutLazyInitialization($object1, 'object-1');
 
// Both objects are uninitalized at this point
 
var_dump($object1); // Object(MyClass) { "propA" => "object-1" }
var_dump($object2); // Object(MyClass) { "propA" => "object-2" }
 
try {
    var_dump($object1->propB); // Exception: initializer exeption
} catch (Exception $e) {
    echo $e->getMessage(), "\n";
}
 
// The state of both objects is unchanged 
 
var_dump($object1); // Object(MyClass) { "propA" => "object-1" }
var_dump($object2); // Object(MyClass) { "propA" => "object-2" }

Detailed API Behavior

ReflectionClass::newLazyGhost()

    public function newLazyGhost(callable $initializer, int $options = 0): object;

The newLazyGhost() method instantiates an object without calling the constructor, and marks the object as lazy.

Properties are not initialized to their default value (they are initialized before calling the initializer). This is only mentioned for completeness, as this is not observable currently.

The $initializer argument is a callable with the following signature:

function (object $object): null {}

When initialization is required, the $initializer is called with the object as first parameter. The initializer should initialize the object, and must return null (or void). See the “Initialization Sequence” section.

The $options argument is a bitfield accepting the following flags:

  • ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE: By default, serializing a lazy object triggers its initialization. This flag disables that behavior, allowing lazy objects to be serialized as empty objects. This is useful in scenarios like Doctrine entities, where cascading serialization could be problematic.

An Error is raised if the class is internal or extends an internal class:

$reflector = new ReflectionClass(ReflectionClass::class);
// Raises "Error: Cannot make instance of internal class lazy: ReflectionClass is internal"
$reflector->newLazyGhost($initializer);

The return value is the created object. Objects whose all properties were initialized are not lazy anymore, as specified in the “Lifecycle of Lazy Objects” section. It follows that the returned object will not be lazy if it has no properties.

The behavior of the returned object is described in the Initialization Triggers and Initialization Sequence sections.

ReflectionClass::newLazyProxy()

    public function newLazyProxy(callable $factory, int $options = 0): object;

The behavior of the newLazyProxy() method is the same as newLazyGhost(), except that it uses the Proxy strategy.

The $factory argument is a callable with the following signature:

function (object $proxy): object {}

When initialization is required, the $factory is called with the proxy as first parameter. The factory should return a new object: the actual instance. See the “Initialization Sequence” section.

ReflectionClass::resetAsLazyGhost()

    public function resetAsLazyGhost(object $object, callable $initializer, int $options = 0): void;

The resetAsLazyGhost() method resets an existing object and marks it as lazy. The indented use-case is for an object to manage its own lazyness by calling the method in its constructor.

The $options argument accepts the same flag as newLazyGhost() in addition to:

  • ReflectionClass::SKIP_DESTRUCTOR: By default, the resetAsLazy*() methods will call the destructor of an object (if any) before making it lazy. This provides safety regarding any preexisting state in the object. But when the object has just been created and is empty, calling the destructor is not desired and can be skipped with this flag.
  • ReflectionClass::SKIP_INITIALIZED_READONLY: By default, the resetAsLazy*() methods will throw an exception if a readonly property was initialized (on the object itself or on a proxy's actual instance) and the class is final. If this flag is set, these properties are skipped and no exception is thrown. The behavior around readonly properties is explained in more details later.
  • ReflectionClass::RESET_INITIALIZED_READONLY: Allow resetAsLazy*() methods to reset initialized readonly properties in cases that are normally not allowed. This flag is subject to a secondary vote (see below).

When making an object lazy, the object destructor is called and the object is reset to a state equivalent to an instance created by newLazyGhost(). In particular, all non-static properties are unset(). This effect could be achieved in user space with the Reflection API and Closure scopes:

(function () {
    $reflector = new ReflectionObject($this);
    foreach ($reflector->getProperties() as $prop) {
        unset($this->{$prop->getName()});
    }
})->bindTo($object, $object);

This snippet omits details such as static, private, readonly, or virtual properties for brevity.

The object is not replaced by an other one, and its identity does not change. Functionality such as spl_object_id(), spl_object_hash(), SplObjectStorage, WeakMap, WeakReference, or strict equality comparison are not affected by resetAsLazy*().

$object = new MyClass();
$ref = WeakReference::create($object);
$id = spl_object_id($object);
 
$reflector = new ReflectionClass(MyClass::class);
 
$reflector->resetAsLazyGhost($object, function () {});
var_dump($id === spl_object_id($object)); // bool(true)
var_dump($ref->get() === $object);        // bool(true)
 
$reflector->initialize($object);
var_dump($id === spl_object_id($object)); // bool(true)
var_dump($ref->get() === $object);        // bool(true)

If the object is already lazy, a ReflectionException is thrown with the message “Object is already lazy”.

Objects whose all properties were initialized are not lazy anymore, as specified in the “Lifecycle of Lazy Objects” section. It follows that calling this method on an object without properties does not make it lazy.

After calling resetAsLazyGhost(), the behavior of the object is the same as an object created by newLazyGhost().

ReflectionClass::resetAsLazyProxy()

    public function resetAsLazyProxy(object $object, callable $initializer, int $options = 0): void;

The behavior of the resetAsLazyProxy() method is the same as resetAsLazyGhost(), except that it uses the Proxy strategy.

The object itself becomes the proxy. Similarly to resetAsLazyGhost(), the object is not replaced by an other one, and its identity does not change, even after initialization. The proxy and the actual instance are distinct objects, with distinct identities.

ReflectionClass::isInitialized()

    public static function isInitialized(object $object): bool;

The isInitialized method returns true if the object was initialized. It also returns true if the object has never been lazy, since an initialized lazy ghost is indistinguishable from an object that was never lazy.

ReflectionClass::initialize()

    public static function initialize(object $object, bool $skipInitializer = false): object;

The initialize() method can be used to force initialization of a lazy object. It has no effect if the object is already initialized.

If $skipInitializer is true, the behavior is the one described for Ghost Objects in the Initialization Sequence section, except that the initializer is not called.

The return value is the object itself for ghost objects (or if $skipInitializer is true), or the actual instance for proxy objects.

ReflectionProperty::skipLazyInitialization()

    public function skipLazyInitialization(object $object): void;

The skipLazyInitialization() method marks a property as non lazy such that it can be accessed directly without triggering initialization. It also initializes the property to its default value, if any.

class MyClass {
    public $id;
    public $b;
}
 
$reflector = new ReflectionClass(MyClass::class);
$object = $reflector->newLazyGhost(function () {});
 
$reflector->getProperty('id')->skipLazyInitialization($object);
 
$object->id = 1;        // does not trigger initialization
var_dump($object->id); // int(1) (does not trigger initialization)

Accessing the property after calling this method has the same behavior as accessing it after constructing the object with ReflectionClass::newInstanceWithoutConstructor(), including throwing errors when accessing uninitialized properties.

The property must be non-dynamic, non-static, and non-virtual.

If the property is not lazy, this method has no effect.

The primary use-case of skipLazyInitialization() and setRawValueWithoutLazyInitialization() is to initialize properties whose value is already known and whose access should not trigger initialization. For example, an ORM may initialize the properties representing the identity of an entity.

ReflectionProperty::setRawValueWithoutLazyInitialization()

    public function setRawValueWithoutLazyInitialization(object $object, mixed $value): void;

The setRawPropertyValue is similar to skipInitializerForProperty(), but it allows to specify a value.

The method does not call hooks, if any, when setting the property value.

The property is marked as non-lazy just before updating its value. If any other property is accessed as a side-effect the update, initialization of the object may be triggered. Such side-effects can be triggered by __toString() on the new value, or __destruct() on the previous value, for example. If an exception prevents updating the update, and the object has not been initialized, the property is marked as lazy again.

Cloning

The result of cloning an uninitialized lazy object is a new lazy object with the same initializer, and the same initialized properties (if ReflectionProperty::setRawValueWithoutLazyInitialization() or skipLazyInitialization() were used).

If the object implements the __clone() magic method, and it accesses properties, initialization may be triggered before the object is returned by the clone operator.

Cloning an initialized lazy proxy returns a clone of the actual instance.

Readonly properties

The proposed changes preserve the semantics of readonly properties. The resetAsLazy*() methods may change the value of a readonly property, but this is already a possibility.

Currently, two consecutive observations of the value of a readonly property can yield different results in the following cases:

  • The property was not initialized at the time of the first observation, and was initialized at the time of the second one
  • The property is unset, and access is intercepted by a __get magic method

The last point implies that it is possible to induce this behavior on an existing class by sub-classing it:

class A {
    public readonly int $prop;
}
class B extends A {
    public int $counter = 0;
    public function __construct() {
        (function () {
            unset($this->prop);
        })->bindTo($this, A::class)();
    }
    public function __get($name) {
        return ++$this->counter;
    }
}
 
$b = new B();
var_dump($b->prop); // int(1)
var_dump($b->prop); // int(2)

It follows that the observable value of a readonly property can change unless the class is final.

We preserve these semantics by never changing or unsetting a readonly property in the makeInstanceLazy*() methods, if the property is initialized (on the object itself or the actual instance, for initialized proxies) and the class is final. Calling resetAsLazy*() on a class with such property will throw an Error. Using the SKIP_INITIALIZED_READONLY flag ignores these properties instead of throwing an Error.

We propose to allow the resetAsLazy*() methods to unset readonly properties in all cases when setting the ReflectionClass::RESET_INITIALIZED_READONLY flags. This capability is subject to a secondary vote so that voters that agree with the rest of this RFC but disagree with this specific flag can express their dissent without rejecting the entire RFC. Nevertheless, we hope voters will recognize the value of this flag for end users, as it would give them more control over the objects they use and would provide consistency over a capability that already exists but not in all cases.

Destructors

The destructor of ghost objects is called if and only if the object has been initialized.

The destructor of proxy objects is never called. We rely on the destructor of the proxied instance instead.

When making an existing object lazy, the resetAsLazy*() methods call the destructor unless the SKIP_DESTRUCTOR flag is given. The rationale is that, unless specified otherwise, we should assume that the constructor was called on this object, therefore the destructor must be called as well before resetting its state entirely.

class Connection {
    public function __construct() {
        $this->connect();
    }
    public function __destruct() {
        $this->close();
    }
}
 
$connection = new Connection();
 
$reflector = new ReflectionClass(Connection::class);
$connection = $reflector->resetAsLazyGhost($connection); // Calls destructor
 
$connection = null; // Does not call destructor (object is not initialized)

About Lazy-Loading Strategies

This RFC proposes adding the ghost and proxy strategies to the engine. One might wonder why two strategies are needed instead of just one.

The most transparent and thus default strategy should be the ghost one. Ghost objects handle initialization in place, meaning that once they are initialized, they are exactly like regular objects.

As an example, the Doctrine ORM implements lazy-loading of entities by employing a user-space implementation of ghost objects. The following snippet illustrates how it would use the proposed API:

// User code
 
class BlogPost
{
    private int $id;
    private string $name;
    private string $email;
}
 
// ORM code
 
class EntityManager
{
    public function getReference(string $class, int $id)
    {
        // The ReflectionClass and ReflectionProperty instances are cached in practice
        $reflector = new ReflectionClass($class);
 
        $entity = $reflector->newLazyGhost(function ($entity) use ($class, $id, $reflector) {
            $data = $this->loadFromDatabase($class, $id);
            $reflector->getProperty('name')->setValue($entity, $data['name']);
            $reflector->getProperty('email')->setValue($entity, $data['email']);
        });
 
        // id is already known and can be accessed without triggering initialization
        $reflector->getProperty('id')->setRawValueWithoutLazyInitialization($entity, $id);
 
        return $entity;
    }
}

This strategy is suitable when we control the instantiation and initialization of the object. This excludes its use when either of these is controlled by an other party.

As an example, the Symfony Dependency Injection component allows to defer the initialization of some parts of the dependency graph by lazy-loading select dependencies. It employs the ghost strategy by default unless the dependency is to be instantiated and initialized by a user-provided factory, in which case it uses the proxy strategy. The following snippet illustrates how it would use the proposed API:

// User code
 
class ClientFactory
{
    public function createClient() {
        return new Client($this->hostname, $this->credentials);
    }
}
 
class Client
{
}
 
// Symfony code
 
class Container
{
    public function getClientService(): Client
    {
        $reflector = new ReflectionClass(Client::class);
 
        $client = $reflector->newLazyProxy(function () use ($container) {
            $clientFactory = $container->get('client_factory');
            return $clientFactory->createClient();
        });
 
        return $client;
    }

About Proxies

When considering proxies, one might expect the implementation to rely on decorating every method of a target class (or interface). This type of proxy is called an inheritance-proxy (not to be confused with state-proxies implemented by this RFC).

Inheritance-proxies decorate every method of a target class or interface to prepend the initialization logic. This logic creates another instance to which all method calls are forwarded. The benefits of this strategy are compatibility with internal classes and interfaces, allowing final classes implementing an interface to be made lazy. However, this strategy has a major drawback: it breaks object identity. If a method returns $this, it returns the decorated object, not the proxy.

The state-proxy strategy proposed by this RFC relies on proxying property accesses instead of methods. Methods are called on the proxy itself, so when a method returns $this, it returns the proxy object. This approach minimizes identity issues. Although minimal, there is still a chance that the actual instance escapes the proxy by creating references to itself during initialization. This is demonstrated by the following snippet:

class Tree {
    public $nodes;
    public function __construct() {
        $this->nodes[] = new Node($this); // '$this' refers to the actual instance
    }
}
$reflector = new ReflectionClass(Tree::class);
$reflector->newLazyProxy(function () {
    return new Tree();
});

Since the state-proxy strategy requires accessing the properties of the decorated object, it is not compatible with internal classes or interfaces. Therefore, inheritance-proxies still have use cases. However, this proposal focuses on providing ghost objects and state-proxies natively, not inheritance-proxies.

There are several reasons for that:

  1. Ghost objects and state-proxies hook into the same place in the engine, simplifying the RFC and the corresponding patch.
  2. These strategies benefit the most from being in the engine: userland implementation relies on complex magic accessors, is difficult to maintain as new PHP versions are released, and is slower than what can be achieved with engine support.
  3. It's unclear if the engine would significantly help with inheritance-proxies: implementing or generating code to decorate methods is simpler.

ReflectionClass::initialize() returns the backing object to aid in implementing inheritance-proxies in userland. The previous description was simplified: inheritance-proxies should also proxy public property accesses in addition to method calls.

Here is an example of a (non-optimized) lazy-loading inheritance-proxy using this RFC:

class Connection
{
    public float $ttl = 1.0;
 
    public function send(string $data): void
    {
        // Real implementation we want to make lazy using decoration
    }
}
 
class LazyConnection extends Connection
{
    public function __construct()
    {
        new ReflectionClass($this)->resetAsLazyProxy($this, $this->initialize(...), ReflectionClass::SKIP_DESTRUCTOR);
    }
 
    public function send(string $data): void
    {
        new ReflectionClass($this)->initialize($this)->send($data);
    }
 
    private function initialize(): parent
    {
        $connection = new parent(); // Or any heavier initialization logic
        $connection->ttl = 2.0;
 
       return $connection;
    }
}
 
$connection = new LazyConnection();
 
echo $connection->ttl; // echoes 2.0

Future scope

Lazy objects are an advanced feature that most users will not use directly. This feature is primarily targeted at library and framework authors.

FFI and Fibers are examples of features recently added to PHP that most users may not use directly, but can benefit from greatly within libraries they use.

As such, the authors do not plan to add higher-level syntax for creating lazy objects.

Furthermore, it is not intended to add class-centric constructs based on attributes or magic methods, as this approach is orthogonal to the objective of this RFC, which is to create lazy objects without requiring cooperation from the class.

However, it is possible to introduce a higher-level syntax or class-centric constructs in a future RFC.

Backward Incompatible Changes

Introduction of new constants and methods in classes ReflectionClass and ReflectionProperty may break sub-classes declaring constants and methods with the same name.

Proposed PHP Version(s)

PHP 8.4

Proposed Voting Choices

* Add lazy-objects as described to the engine: yes/no (2/3 required to pass)

* Allow the ReflectionClass::resetAsLazy*() methods to unset readonly properties in all cases when setting the ReflectionClass::RESET_INITIALIZED_READONLY flags (1/2 required to pass)

Patches and Tests

rfc/lazy-objects.txt · Last modified: 2024/06/22 14:33 by lbarnaud