In Symfony 6.2, the VarExporter component will ship two new traits to help implement lazy-loading objects.

As their names suggest, lazy-loading objects are initialized only when actually needed; typically when accessing one of their properties. They’re used when an object is heavy to instantiate but is not always used.

There are two main use cases for lazy-objects: lazy services and lazy entities.

  • You can find lazy services in e.g. the Symfony dependency-injection container. Here is an excerpt from the documentation:

    Imagine you have a NewsletterManager and you inject a mailer service into it. Only a few methods on your NewsletterManager actually use the mailer, but even when you don’t need it, a mailer service is always instantiated in order to construct your NewsletterManager.

    By making it lazy, the mailer service won’t be initialized unless NewsletterManager actually sends an email.

  • You can find lazy entities in e.g. Doctrine ORM where they’re used to create entities and collections that aren’t yet populated. Only when first-accessing any of their properties will lazy-initialization retrieve their state by doing SQL queries.

If you know about the concept already, you certainly know about the ocramius/proxy-manager library. Although Doctrine ORM uses its own implementation, this library is the de facto implementation of lazy-loading proxies in PHP. That’s the package we’ve been using for the Symfony container since 2013 with the introduction of the symfony/proxy-manager-bridge. Huge kudos to its authors, the work is impressive and inspiring at many levels.

Unfortunately, 1.5 years ago, due to incompatibilities between the maintenance policies of Symfony and the maintenance policies of ProxyManager, we’ve decided to maintain a fork that you might already be using: friendsofphp/proxy-manager-lts. This fork is kept in sync with the original library but is patched:

  1. to support a wide range of PHP and Composer versions,
  2. to fix some behaviors that used to require monkey-patching on the side of proxy-manager-bridge (e.g. skipping destructors on uninitialized instances or compatibility with fluent APIs),
  3. and to support newer PHP versions (to date, ProxyManager doesn’t support PHP 8.1 but we require this version in Symfony 6.1.)

Don’t get me wrong, the problems I describe here are created by the way we use that code - not by the origin. Open-Source dynamics mean authors owe absolutely nothing to the users of their code. It also means that contributing back might be desired. That’s why we’ve sent back all changes that made sense. 🤞

But this situation is less than ideal as it creates friction and frustration.

Fixing this is the #1 reason I wrote the two traits mentioned earlier.

Reason #2 is a technical one that’s in my mind since years: “Could we replace the code generated by ProxyManager by a few generic traits?” You figured out already, I figured out for you, the answer is “Yes!”. That’s huge because it means we can move the complexity of lazy-loading implementations into a few files that are easy to audit.

So here we are. Let me introduce you to LazyGhostTrait and to LazyProxyTrait.

By using LazyGhostTrait, you can add lazy-loading capabilities to a class. This works by creating empty instances (unsetting all their properties) and by computing their state only when accessing a property, either directly or indirectly (by calling a method.) Here is an example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FooLazyGhost extends Foo
{
    use LazyGhostTrait;

    private int $lazyObjectId;
}

$foo = FooLazyGhost::createLazyGhost(initializer: function (Foo $instance): void {
    // [...] Use whatever heavy logic you need here
    // to compute the $dependencies of the $instance
    $instance->__construct(...$dependencies);
    // [...] Call setters, etc. if needed
});

// $foo is now a lazy-loading ghost object. The initializer will
// be called only when and if a *property* is accessed.

You can also partially initialize the objects on a property-by-property basis by adding two arguments to the initializer:

1
2
3
4
5
6
$initializer = function (Foo $instance, string $propertyName, ?string $propertyScope): mixed {
    if (Foo::class === $propertyScope && 'bar' === $propertyName) {
        return 123;
    }
    // [...] Add more logic for the other properties
};

Alternatively, LazyProxyTrait can be used to create virtual proxies:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$proxyCode = ProxyHelper::generateLazyProxy(new ReflectionClass(Foo::class));
// $proxyCode contains the reference to LazyProxyTrait
// and should be dumped into a file in production envs
eval('class FooLazyProxy'.$proxyCode);

$foo = FooLazyProxy::createLazyProxy(initializer: function (): Foo {
    // [...] Use whatever heavy logic you need here
    // to compute the $dependencies of the $instance
    $instance = new Foo(...$dependencies);
    // [...] Call setters, etc. if needed

    return $instance;
});
// $foo is now a lazy-loading virtual proxy object. The initializer will
// be called only when and if a *method* is called.

As you might have noted, this code uses a ProxyHelper class to generate some boilerplate. This code generation is totally optional as you might decide to use the trait directly. I’ve done so in this PR to ship a lazy-loading Redis class in the Cache component.

Ghost objects work only with concrete and non-internal classes. In the generic case, they are not compatible with using factories in their initializer.

Virtual proxies work with concrete, abstract or internal classes. They provide an API that looks like the actual objects and forward calls to them. They can cause identity problems because proxies might not be seen as equivalents to the actual objects they proxy.

On this identity topic, LazyProxyTrait is able to proxy only the properties of an implementation. As a consequence, when a method return $this; (or clone $this), the $this in question is the proxy itself, and not the decorated instance. This means that fluent and wither APIs work just fine! (For performance reasons and for internal classes, decorating methods can still be generated.)

Exceptions thrown by the ProxyHelper class can help decide which trait works best with a specific class.

Ghost objects and virtual proxies both provide implementations for the LazyObjectInterface which allows resetting them to their initial state or to forcibly initialize them when needed. Note that resetting a ghost object skips its read-only properties. You should use a virtual proxy to reset read-only properties.

The DependencyInjection component will start using these traits in Symfony 6.2. Please give it a try and report back if you find any issues of course!

For more info about this work, check these PRs:

Enjoy!