Revisiting Lazy-Loading Proxies in PHP

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:

to support a wide range of PHP and Composer versions,
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),
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:

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:

$initializer = function (Foo $instance, string $propertyName, ?string $propertyScope): mixed {
if (Foo::class === $propertyScope && ‚bar‘ === $propertyName) {
return 123;
}
// […] Add more logic for the other properties
};

Note that lazy-initialization is not triggered when only setting or unsetting
a property. You can call setters, this won’t initialize the ghost object.

Alternatively, LazyProxyTrait can be used to create virtual proxies:

$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:

Add trait to help implement lazy-loading ghost objects
Use lazy-loading ghost object proxies out of the box
Generate lazy-loading virtual proxies for non-ghostable lazy services

Enjoy!

Sponsor the Symfony project.

Symfony Blog
Read More

Latest News

PHP-Releases

PHP 8.0.23 released!

PHP 8.1.10 released!

PHP 7.4.30 released!