Revisiting Lazy-Loading Proxies in PHP
September 3, 2022 • Published by Nicolas Grekas
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 amailer
service into it. Only a few methods on yourNewsletterManager
actually use themailer
, but even when you don’t need it, amailer
service is always instantiated in order to construct yourNewsletterManager
.By making it lazy, the
mailer
service won’t be initialized unlessNewsletterManager
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:
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:
- 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!
Help the Symfony project!
As with any Open-Source project, contributing code or documentation is the most common way to help, but we also have a wide range of sponsoring opportunities.
Comments are closed.
To ensure that comments stay relevant, they are closed for old posts.
Why not use a method for the initializer inside of the class?
@Yani In most cases, the initializer callback will need to have access to additional dependencies (in the case of the DI component, it has access to the Container itself to get dependencies). Making it a method in the class would mean that the callable is owned by the lazy object, not by the code instantiating it, and that would break that use case.
@Yani additionally to what @stof said, I've also wondered about this (in addition to the current capability). It might be interesting to see when and how this would work. Feel free to give it a try :)