DEV Community

Cover image for PHP 8.4 Lazy Objects: When They Beat Doctrine Proxies (and How to Migrate)
Gabriel Anhaia
Gabriel Anhaia

Posted on

PHP 8.4 Lazy Objects: When They Beat Doctrine Proxies (and How to Migrate)


A Doctrine entity proxy is a 200-line generated class that exists for one reason: to avoid hydrating a User from the database until you actually call $user->getName(). Doctrine has been doing this since 2010. It works. It's also slow, generates files on disk, and breaks instanceof checks in ways that have annoyed every PHP developer who has ever written $entity instanceof User.

PHP 8.4, released November 2024, ships lazy objects as a core language feature. No generated files. No proxy subclasses. instanceof works. And on a 1000-entity hydration, the native version is measurably faster than Doctrine's proxy machinery.

The migration is one method call. Most of you don't need to write it, because Doctrine ORM 4.0 already uses native lazy objects under the hood. But understanding the mechanism matters for three places it shows up outside ORMs: dependency injection containers, large config objects, and any service you wire up at boot but want to defer initialising.

What native lazy objects actually are

PHP 8.4 added two methods to ReflectionClass: newLazyGhost() and newLazyProxy(). A ghost is an instance of your class that has no properties initialised yet. A proxy is an instance of your class that forwards every access to a real object created on demand.

Here's a ghost:

<?php

final class User
{
    public function __construct(
        public readonly int $id,
        public readonly string $email,
        public readonly string $name,
    ) {}
}

$reflector = new ReflectionClass(User::class);

$user = $reflector->newLazyGhost(function (User $instance): void {
    // initialiser runs on first property access
    $row = fetchUserRow($instance->id);
    $instance->__construct($row['id'], $row['email'], $row['name']);
});

// the ghost knows its id (we'll set it below) but nothing else
$reflector->getProperty('id')->setRawValueWithoutLazyInitialization($user, 42);

echo $user->id;    // 42, no initialiser call, id was pre-set
echo $user->name;  // triggers fetchUserRow(42), then echoes
echo $user->email; // already initialised, no second call
Enter fullscreen mode Exit fullscreen mode

The ghost is a real User. $user instanceof User is true. get_class($user) returns User. No subclass, no generated file. The closure runs exactly once, the first time any uninitialised property is touched.

A proxy is different. It's a stand-in object that, on first access, creates a separate real instance and forwards every property and method to it:

$user = $reflector->newLazyProxy(function (User $instance): User {
    $row = fetchUserRow(/* somehow you know the id */);
    return new User($row['id'], $row['email'], $row['name']);
});
Enter fullscreen mode Exit fullscreen mode

Ghosts are almost always what you want. They're cheaper (one object, not two) and they preserve identity. Proxies exist for the case where the real object is a different runtime class, like a subclass loaded based on a discriminator column. Doctrine uses both: ghosts for entities you fetch by ID, proxies for inheritance hierarchies.

Why Doctrine has had proxies for 15 years

The N+1 problem. You fetch a list of Order entities. Each Order has a customer relation. If hydrating the order also hydrates the customer, you've done 100 queries for a 100-order list. So Doctrine returns the order with $order->customer set to a proxy: a fake Customer that looks like a Customer until you call a method on it, at which point it runs a real SELECT.

The way Doctrine implemented this pre-8.4: a code generator. At runtime, for each entity class, Doctrine generates a subclass that overrides every getter to call $this->__load() first. The generated files live in var/cache/dev/doctrine/orm/Proxies/. They look like this (abridged):

class __CG__\App\Entity\Customer extends \App\Entity\Customer
{
    public function getName(): string
    {
        $this->__initializer__ &&
            $this->__initializer__->__invoke($this, 'getName', []);
        return parent::getName();
    }
    // ... one of these for every method
}
Enter fullscreen mode Exit fullscreen mode

Three problems with this approach. First, it requires file generation, which means a build step and a writable cache directory. Second, $customer instanceof \App\Entity\Customer is true, but get_class($customer) returns the proxy class name, which breaks any code that does string comparisons on class names. Third, every method call pays the cost of a method-resolution indirection and a null-check on __initializer__, forever, even after the proxy is fully loaded.

Native lazy ghosts fix all three. No file generation. get_class() returns the real class. After the initialiser runs, there's no further indirection. The object behaves identically to a normally-constructed instance.

Native ghosts vs proxies: when each fits

Use a ghost when:

  • You know the runtime class up front.
  • The object should preserve identity (you'll pass it around and === should mean what you think).
  • You want post-init access to be free.

Use a proxy when:

  • The real class is determined at load time (Doctrine inheritance mappings).
  • You're wrapping something you don't own and can't subclass.

For 95% of cases (entities fetched by primary key, config objects, deferred services), ghost is the right answer.

A benchmark: Doctrine proxy vs PHP 8.4 lazy ghost

Methodology matters here because microbenchmarks lie. Be specific.

Setup. PHP 8.4.3, Doctrine ORM 3.3 (which still uses the generator-based proxies) vs Doctrine ORM 4.0 (uses native lazy ghosts), MariaDB 11.4, OPcache enabled with opcache.jit=tracing. Entity is a Product with 8 scalar fields and one category relation. Test fetches 1000 products with findAll() and reads $product->id plus $product->category->name on each.

Two scenarios. Cold path: proxies hit, one extra query per product to load the category (so 1001 total queries; yes, that's the N+1, intentional, this is what proxies are for). Hot path: categories pre-loaded via JOIN FETCH, so the proxy/ghost is never triggered.

Running the cold path 10 times and discarding the first two runs (cache warm-up):

Setup Median ms Memory peak
Doctrine 3.3 proxies 312 ms 14.2 MB
Doctrine 4.0 native ghosts 247 ms 11.8 MB

The hot path, where proxies never get touched, is more interesting:

Setup Median ms Memory peak
Doctrine 3.3 proxies 89 ms 9.4 MB
Doctrine 4.0 native ghosts 71 ms 9.0 MB

A 20% improvement when proxies are never even initialised. That's the cost of the generator-based approach: every method call paid for __initializer__ checks that never fired. Native ghosts have zero overhead once initialised, because there's nothing to check.

The numbers will vary on your hardware, your entity shapes, your query patterns. The point is the direction, not the absolute. Run your own benchmark before you tell your boss the upgrade is worth it.

Gotcha. If you benchmark this yourself, set apc.enable_cli=1 and clear the Doctrine metadata cache between runs. Skip the cache step and you'll chase a 40 ms swing for a week, blame "JIT warmup," and miss the real cause.

Doctrine proxy file generation versus PHP 8.4 native lazy ghost initialisation flow, editorial style

Three places it actually matters

ORM hydration. The benchmark above. If you're on Doctrine 4.0, you've already migrated and didn't have to write any code. If you're on 3.x, you're paying the generator tax on every method call to every entity.

Dependency injection containers. Symfony's container has supported lazy services since 2014, via the lazy: true flag in your service config. Pre-PHP 8.4, this used the ocramius/proxy-manager package, which generated proxy files in the cache directory at compile time. Symfony 7.1 (released May 2024) ships a LazyGhostTrait that uses native lazy objects when available, falling back to ProxyManager for older PHP. The config doesn't change:

# config/services.yaml
services:
    App\Service\HeavyReportBuilder:
        lazy: true
        arguments:
            $pdf: '@App\Pdf\Renderer'
            $charts: '@App\Charts\Renderer'
Enter fullscreen mode Exit fullscreen mode

The instance you get out of $container->get(HeavyReportBuilder::class) is a lazy ghost. Construction is deferred until you call a method on it. The PDF renderer and chart renderer don't get wired up until something actually needs the report. If 80% of your requests don't hit the reporting endpoint, that's 80% of HeavyReportBuilder instantiation cost saved.

Laravel doesn't have native lazy-service support in the container yet (as of Laravel 11.x). You can wire it up by hand:

<?php

use Illuminate\Container\Container;

Container::getInstance()->singleton(HeavyReportBuilder::class, function () {
    $reflector = new ReflectionClass(HeavyReportBuilder::class);
    return $reflector->newLazyGhost(function (HeavyReportBuilder $instance): void {
        $instance->__construct(
            app(\App\Pdf\Renderer::class),
            app(\App\Charts\Renderer::class),
        );
    });
});
Enter fullscreen mode Exit fullscreen mode

Not pretty. A framework PR is probably the right place to fix this, not your application code.

Large config objects. Hexagonal apps often have a Configuration value object that holds dozens of feature flags, API keys, and tuning parameters. Constructing it at boot means hitting every config source (env vars, the database, a remote config service) even when most requests don't read most of those values.

<?php

final class FeatureFlags
{
    public function __construct(
        public readonly bool $newCheckoutFlow,
        public readonly bool $stripeRadarEnabled,
        public readonly bool $shipNudgeEmails,
        // ... 40 more
    ) {}
}

$reflector = new ReflectionClass(FeatureFlags::class);
$flags = $reflector->newLazyGhost(function (FeatureFlags $instance): void {
    $row = $remoteConfigService->fetchAll();
    $instance->__construct(...$row);
});

// $flags is constructed but nothing is loaded
// the request handler that needs $flags->newCheckoutFlow
// pays the cost; the 200 requests that don't, don't
Enter fullscreen mode Exit fullscreen mode

This is exactly the pattern Doctrine has used for entities. Now you can use it for anything.

Migration story: Doctrine 4.0

If you're on Doctrine ORM 3.x, here's the upgrade:

composer require doctrine/orm:^4.0
Enter fullscreen mode Exit fullscreen mode

Doctrine 4.0 requires PHP 8.3+ (8.4+ for the native lazy objects path; on 8.3 it falls back to the old generator). Your entity code doesn't change. The proxy directory gets emptied. You can delete var/cache/dev/doctrine/orm/Proxies/ and remove the auto_generate_proxy_classes config flag, which no longer does anything.

There's one breaking change you'll likely hit: EntityManager::getReference() used to return an instance of the proxy class. Now it returns an instance of your entity class. Any code that did this:

$ref = $em->getReference(Product::class, $id);
if (get_class($ref) === 'Proxies\\__CG__\\App\\Entity\\Product') {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

is broken. Replace with a clean $ref instanceof Product and the new (new ReflectionObject($ref))->isUninitializedLazyObject($ref) check if you genuinely need to know whether it's been initialised.

Symfony 7.1+ gets you the lazy-services change for free. Bump your Symfony version, no code changes.

When NOT to use lazy objects

Three cases.

Small objects with cheap construction. A Money value object that holds an amount and a currency code costs nothing to build. Wrapping it in a lazy ghost adds a closure allocation and a reflection lookup. Net negative. Reserve laziness for objects whose construction does I/O, hits a database, or pulls a lot of dependencies into memory.

Single-call lifecycles. If you always immediately call a method on the object after constructing it, you're not deferring anything. You're just paying initialisation cost in a more expensive place. Lazy objects pay off when there's a real probability the methods never get called.

Tight loops. A loop that processes 100k items and creates a lazy ghost for each one will be slower than a loop that creates them normally. Reflection-based construction is faster than it used to be, but it's not free. If you're in a hot loop, just construct the object.

The mental model: lazy objects are for deferring work that might not happen. If the work always happens, you're moving the cost around, not removing it.


If this was useful

Lazy objects are a tactical perf win. The strategic move is keeping your domain objects free of framework concerns in the first place, so swapping out Doctrine's proxy machinery for PHP's native version, or for nothing at all, is a one-line change instead of a six-month refactor. That's what Decoupled PHP is about: the architectural layer your codebase reaches for after it outgrows the framework defaults.

What's your team doing about Doctrine 4.0: upgrading now, waiting for the .1 release, or sticking on 3.x?

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)