Lazy objects allow you to delay initialization until it’s absolutely necessary. This is particularly useful when an object depends on I/O operations—such as accessing a database or making an external HTTP request. Although you could previously implement lazy loading in userland, there were significant caveats. For example, you couldn’t declare the proxied class as final, because the lazy proxy must extend it to satisfy type checks. If you’ve ever used Doctrine, you might have noticed that entities cannot be declared final for precisely this reason.
Without further ado, let's dive right in!
Lazy deserializer
For this project, I created a simple DTO:
final readonly class Product
{
public function __construct(
public string $name,
public string $description,
public float $price,
) {
}
}
Notice that the class is declared as both final
and readonly
—something that wouldn’t have been possible with a pure userland implementation. Here’s what the deserializer looks like:
final readonly class LazyDeserializer
{
/**
* @template T of object
* @param class-string<T> $class
* @return T
*/
public function deserialize(array $data, string $class): object
{
// todo
}
}
This setup lets us write code like the following:
$data = [
'name' => 'Door knob',
'description' => "The coolest door knob you've ever seen!",
'price' => 123.45,
];
$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);
var_dump($object);
Implementing the deserializer
I split the implementation into multiple methods for better maintainability. Let’s start with the single public method whose signature we just saw:
/**
* @template T of object
* @param class-string<T> $class
* @return T
*/
public function deserialize(array $data, string $class): object
{
$reflection = new ReflectionClass($class);
return $reflection->newLazyGhost(function (object $object) use ($data): void {
$this->deserializeObject($data, $object);
});
}
First, we obtain a reflection of the target class and then call its newLazyGhost
method. The lazy ghost is responsible for creating the lazily initialized object. It accepts a single callback that receives an instance of the target object (which remains uninitialized) and uses it to set up the properties in the deserializeObject
method.
At this point, the method returns an object of the target class (specified by the $class
parameter) with all its properties uninitialized. These properties will be initialized only when you access them. For example, if you var_dump
the resulting object right now, you might see something like:
lazy ghost object(App\Dto\Product)#7 (0) {
["name"]=>
uninitialized(string)
["description"]=>
uninitialized(string)
["price"]=>
uninitialized(float)
}
Notice that it doesn’t matter that the private deserializeObject
method isn’t implemented yet—the object remains truly lazy. Any errors related to initialization will only appear when you try to access one of its uninitialized properties.
Here's an implementation of the private method:
private function deserializeObject(array $data, object $object): void
{
$reflection = new ReflectionObject($object);
foreach ($reflection->getProperties(ReflectionProperty::IS_PUBLIC) as $property) {
if (!isset($data[$property->getName()])) {
if ($property->getType()?->allowsNull()) {
$property->setValue($object, null);
}
continue;
}
$property->setValue($object, $data[$property->getName()]);
unset($data[$property->getName()]);
}
if (count($data)) {
throw new LogicException('There are left-over data in the array which could not be deserialized into any property.');
}
}
I’m using reflection here because the object is marked as readonly
—this is the only way to set a readonly property outside the constructor. If the properties weren’t readonly, you could simply assign values directly (e.g. $object->$propertyName = $value
).
The process is straightforward: we iterate over each public property of the class, assign the corresponding value from the data array, and if a property is missing (and its type allows null
), we set it to null
. Finally, we ensure there’s no leftover data, which would indicate a mismatch between the data and the model. (Note that this is a naive implementation; real-world deserializers tend to be more robust.)
Now, let’s modify the previous example slightly to trigger the initialization of the model:
$data = [
'name' => 'Door knob',
'description' => "The coolest door knob you've ever seen!",
'price' => 123.45,
];
$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);
var_dump($object); // this will print the uninitialized model
$object->name; // simply calling a property will force the object to initialize
var_dump($object); // this now prints:
// object(App\Dto\Product)#7 (3) {
// ["name"]=>
// string(9) "Door knob"
// ["description"]=>
// string(39) "The coolest door knob you've ever seen!"
// ["price"]=>
// float(123.45)
//}
Note that this implementation isn’t very useful on its own since it merely assigns properties from a static array—there’s no I/O involved. Let’s enhance it to support deserializing more complex values, such as enums, nested objects, and (most importantly) I/O-bound entities (which we’ll simulate with an HTTP request). First, instead of directly assigning the value, I add another private method:
$property->setValue($object, $this->assignValue($property, $data[$property->getName()]));
Now, let’s implement assignValue
:
private function assignValue(ReflectionProperty $property, mixed $value): mixed
{
$type = $property->getType();
if (!$type) {
return $value;
}
if ($value === null && $type->allowsNull()) {
return null;
}
if (!$type instanceof ReflectionNamedType) {
throw new LogicException('Only a single type is allowed');
}
$typeName = $type->getName();
if (is_a($typeName, BackedEnum::class, true)) {
return $typeName::from($value);
} else if (is_array($value) && class_exists($typeName)) {
return $this->deserialize($value, $typeName);
} else if ($this->isHttpEntity($typeName) && is_string($value)) {
return $this->fetchHttpEntity($typeName, $value);
}
return $value;
}
Here’s what happens in assignValue
:
- If the property has no type, the value is returned as is.
- If the value is
null
and the type is nullable,null
is returned. - An exception is thrown if the type isn’t a single named type (supporting multiple types would add too much complexity for this example).
- Three cases are then handled:
- If the type is a backed enum, we convert the value using its built-in
from
method. - If the value is an array and the type corresponds to an existing class, we recursively call
deserialize
to support nested objects. - If the type is marked as a HTTP entity (using the
HttpEntity
attribute) and the value is a string, we assume it represents an ID and fetch the entity.
- If the type is a backed enum, we convert the value using its built-in
Here are some more objects that the deserializer now supports:
enum Availability: int
{
case InStock = 1;
case OnTheWay = 2;
case OutOfStock = 3;
}
final readonly class ProductVariant
{
public function __construct(
public string $color,
public string $size,
) {
}
}
#[HttpEntity]
final readonly class Seller
{
public function __construct(
public string $id,
public string $name,
public float $rating,
) {
}
}
For completeness, here’s the definition of the HttpEntity
attribute and a helper method to check for it:
#[Attribute(Attribute::TARGET_CLASS)]
final readonly class HttpEntity
{
}
private function isHttpEntity(string $typeName): bool
{
if (!class_exists($typeName)) {
return false;
}
$reflection = new ReflectionClass($typeName);
$attributes = $reflection->getAttributes(HttpEntity::class);
return count($attributes) > 0;
}
The enum and the non-HTTP entity class work out of the box. For example:
final readonly class Product
{
public function __construct(
public string $name,
public string $description,
public float $price,
public Availability $availability,
public ?ProductVariant $variant = null,
) {
}
}
$data = [
'name' => 'Door knob',
'description' => "The coolest door knob you've ever seen!",
'price' => 123.45,
'availability' => 2,
'variant' => [
'color' => 'golden',
'size' => '3',
],
];
$deserializer = new LazyDeserializer();
$object = $deserializer->deserialize($data, Product::class);
var_dump($object);
// lazy ghost object(App\Dto\Product)#7 (0) {
// ["name"]=>
// uninitialized(string)
// ["description"]=>
// uninitialized(string)
// ["price"]=>
// uninitialized(float)
// ["availability"]=>
// uninitialized(App\Enum\Availability)
// ["variant"]=>
// uninitialized(?App\Dto\ProductVariant)
//}
$object->name;
var_dump($object);
// object(App\Dto\Product)#7 (5) {
// ["name"]=>
// string(9) "Door knob"
// ["description"]=>
// string(39) "The coolest door knob you've ever seen!"
// ["price"]=>
// float(123.45)
// ["availability"]=>
// enum(App\Enum\Availability::OnTheWay)
// ["variant"]=>
// lazy ghost object(App\Dto\ProductVariant)#19 (0) {
// ["color"]=>
// uninitialized(string)
// ["size"]=>
// uninitialized(string)
// }
//}
$object->variant->color;
// object(App\Dto\Product)#7 (5) {
// ["name"]=>
// string(9) "Door knob"
// ["description"]=>
// string(39) "The coolest door knob you've ever seen!"
// ["price"]=>
// float(123.45)
// ["availability"]=>
// enum(App\Enum\Availability::OnTheWay)
// ["variant"]=>
// object(App\Dto\ProductVariant)#19 (2) {
// ["color"]=>
// string(6) "golden"
// ["size"]=>
// string(1) "3"
// }
//}
Notice that the variant
property is also lazily initialized—which is pretty neat. Every nested object is handled lazily.
I/O bound entities
Now, let’s move on to HTTP entities. We’ll create a service that “fetches” them (in this case, we’ll simulate the fetch):
final readonly class HttpEntityFetcher
{
public function fetchRawByIdAndType(string $id, string $type): ?array
{
sleep(1);
return [
'id' => $id,
'name' => 'Cool seller',
'rating' => 4.9,
];
}
}
Here, I simulate a slow HTTP request that takes one second to complete and returns JSON data (already decoded into an array). Note that for this example the fetch always returns a seller.
Now all that’s missing is the LazyDeserializer::fetchHttpEntity()
method:
public function __construct(
private HttpEntityFetcher $entityFetcher,
) {
}
/**
* @template T of object
*
* @param class-string<T> $typeName
* @return T|null
*/
private function fetchHttpEntity(string $typeName, string $id): ?object
{
return new ReflectionClass($typeName)->newLazyGhost(function (object $object) use ($typeName, $id): void {
$data = $this->entityFetcher->fetchRawByIdAndType($id, $object::class);
if (!is_array($data)) {
throw new InvalidArgumentException('An object of type ' . $typeName . ' with id ' . $id . ' could not be fetched.');
}
$this->deserializeObject($data, $object);
});
}
This lazy ghost postpones the HTTP request until one of the object’s properties is actually accessed. Next, let’s add the seller property to our product:
final readonly class Product
{
public function __construct(
public string $name,
public string $description,
public float $price,
public Availability $availability,
public Seller $seller,
public ?ProductVariant $variant = null,
) {
}
}
And here’s an example that adds some timing measurements to our deserialization:
$data = [
'name' => 'Door knob',
'description' => "The coolest door knob you've ever seen!",
'price' => 123.45,
'availability' => 2,
'variant' => [
'color' => 'golden',
'size' => '3',
],
'seller' => 'some-seller-id',
];
$deserializer = new LazyDeserializer(new HttpEntityFetcher());
$start = microtime(true);
$object = $deserializer->deserialize($data, Product::class);
$end = microtime(true);
echo "Deserializer took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
$start = microtime(true);
$object->seller->name;
$end = microtime(true);
echo "Fetching seller id took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
On my PC, this prints:
Deserializer took: 0.0000250340 seconds
Fetching seller name took: 1.0002360344 seconds
The deserialization is nearly instantaneous—the delay comes when the HTTP request is eventually executed during initialization.
Partially initializing ghost objects
In the example above, there’s one piece of information we already know about the seller even before any HTTP request is made: its ID. Triggering a network call just to obtain the ID is unnecessary. Fortunately, we can initialize that property immediately:
/**
* @template T of object
*
* @param class-string<T> $typeName
* @return T|null
*/
private function fetchHttpEntity(string $typeName, string $id): ?object
{
$reflection = new ReflectionClass($typeName);
$entity = $reflection->newLazyGhost(function (object $object) use ($typeName, $id): void {
$data = $this->entityFetcher->fetchRawByIdAndType($id, $object::class);
if (!is_array($data)) {
throw new InvalidArgumentException('An object of type ' . $typeName . ' with id ' . $id . ' could not be fetched.');
}
unset($data['id']);
$this->deserializeObject($data, $object);
});
$reflection->getProperty('id')->setRawValueWithoutLazyInitialization($entity, $id);
return $entity;
}
The setRawValueWithoutLazyInitialization
method (a catchy name, right?) lets you assign a value to a property without forcing the rest of the object to be initialized.
$start = microtime(true);
$object = $deserializer->deserialize($data, Product::class);
$end = microtime(true);
echo "Deserializer took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);
$start = microtime(true);
$object->seller->id;
$end = microtime(true);
echo "Fetching seller id took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);
$start = microtime(true);
$object->seller->name;
$end = microtime(true);
echo "Fetching seller name took: " . number_format($end - $start, 10) . " seconds", PHP_EOL;
var_dump($object->seller);
This prints timings similar to:
Deserializer took: 0.0000338554 seconds
Fetching seller id took: 0.0000009537 seconds
Fetching seller name took: 1.0001599789 seconds
As you can see, accessing the ID is immediate, while accessing another property (like the name) triggers the full initialization.
lazy ghost object(App\Entity\Seller)#20 (1) {
["id"]=>
string(14) "some-seller-id"
["name"]=>
uninitialized(string)
["rating"]=>
uninitialized(float)
}
lazy ghost object(App\Entity\Seller)#20 (1) {
["id"]=>
string(14) "some-seller-id"
["name"]=>
uninitialized(string)
["rating"]=>
uninitialized(float)
}
object(App\Entity\Seller)#20 (3) {
["id"]=>
string(14) "some-seller-id"
["name"]=>
string(11) "Cool seller"
["rating"]=>
float(4.9)
}
That’s it for the deserializer example! It’s a simplified implementation, but I imagine that Doctrine may eventually replace its userland proxy approach with these core lazy objects once they target PHP 8.4 and later.
Private key generating example
As a bonus, here’s an additional example—a private key generator that I’ve actually used in one of my libraries. (View on GitHub)
public function generate(int $bits = 4096): KeyPair
{
$reflection = new ReflectionClass(KeyPair::class);
$keyPair = $reflection->newLazyGhost(function (KeyPair $keyPair) use ($bits) {
$config = [
'private_key_type' => OPENSSL_KEYTYPE_RSA,
'private_key_bits' => $bits,
];
$resource = openssl_pkey_new($config) ?: throw new CryptographyException('Failed generating new private key');
$privateKeyPem = '';
openssl_pkey_export($resource, $privateKeyPem);
assert(is_string($privateKeyPem));
$details = openssl_pkey_get_details($resource) ?: throw new CryptographyException('Failed decoding the private key');
$publicKeyPem = $details['key'];
assert(is_string($publicKeyPem));
$reflection = new ReflectionObject($keyPair);
$reflection->getProperty('privateKey')->setValue($privateKeyPem);
$reflection->getProperty('publicKey')->setValue($publicKeyPem);
return $keyPair;
});
assert($keyPair instanceof KeyPair);
return $keyPair;
}
This postpones the expensive operation (generating a 4096 bits private key) until it's actually needed.
![](https://chrastecky.dev/i/o/9/Lazy%20ghost%20objects.png)