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 aNewsletterManager
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, themailer
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:
You can also partially initialize the objects on a property-by-property basis by adding two arguments to the initializer:
Alternatively, LazyProxyTrait
can be used to create virtual proxies:
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!
Originally published at https://symfony.com.