PHP 8.4 Lazy Objects: Defer Expensive Initialization in Laravel

PHP 8.4 lazy objects defer expensive construction until an object is actually used. Learn lazy ghosts vs proxies, the Reflection API, and the Laravel angle.

Steven Richardson
Steven Richardson
· 7 min read

You type-hint a ReportBuilder in a controller, Laravel resolves it before the method body runs, and the constructor opens a connection and aggregates ten thousand rows. Then the request takes a branch that never calls the report. You just paid for an object you didn't use. PHP 8.4 lazy objects fix exactly this: the constructor is deferred until something actually observes the object's state.

Before 8.4 the only options were hand-rolled proxy classes or container deferral tricks, both of which leak the laziness into your code. Now it's a language feature, driven through Reflection. This is the same machinery PHP 8.4 property hooks sit alongside, and it's worth understanding before you reach for it.

Identify an expensive service to make lazy#

Start with a class whose constructor does real work — network calls, query aggregation, building a large in-memory structure. That construction cost is what you want to defer. A lazy object is only worth it when the work is both expensive and conditional; if every code path uses the object immediately, laziness just adds Reflection overhead for no gain.

final class ReportBuilder
{
    /** @var list<int> */
    private array $dataset;

    public function __construct(public readonly int $accountId)
    {
        // Pretend this is expensive: queries, aggregation, a warm cache.
        echo "Booting ReportBuilder for account {$accountId}\n";
        $this->dataset = $this->loadAndAggregate();
    }

    public function rowCount(): int
    {
        return count($this->dataset);
    }

    /** @return list<int> */
    private function loadAndAggregate(): array
    {
        return range(1, 10_000);
    }
}

Create a lazy ghost with newLazyGhost#

A lazy ghost is the right default when you control both how the object is created and how it's initialised. You get back an instance of the real class, but its properties are uninitialised and the constructor hasn't run. The entry point is ReflectionClass::newLazyGhost(), which takes an initializer closure receiving the half-built object as its only argument.

use ReflectionClass;

$accountId = 42;
$reflector = new ReflectionClass(ReportBuilder::class);

$report = $reflector->newLazyGhost(function (ReportBuilder $object) use ($accountId): void {
    // Runs only when $report's state is first touched. Initialise in place.
    $object->__construct($accountId);
});

// Nothing has printed yet — the constructor has not run.

The closure must initialise the object in place (typically by calling __construct()) and return null. At this point $report is genuinely a ReportBuilder: it passes instanceof, and code receiving it has no idea it's lazy.

Trigger initialization and inspect the initializer#

Initialization fires the moment any code observes or modifies the object's state. The cleanest way to prove deferral is to do nothing dangerous first, then touch a property. The manual is precise about what counts as a trigger: reading or writing a property, isset/unset, get_object_vars(), foreach iteration, serialize()/json_encode(), and cloning. Notably, var_dump() and an (array) cast do not trigger it, which makes them safe for debugging a still-lazy object.

// var_dump shows the lazy, uninitialised object — no constructor call:
var_dump($report);
// lazy ghost object(ReportBuilder)#3 { ["accountId"]=> uninitialized(int) ... }

// This call reads $this->dataset, so it triggers initialization now:
echo $report->rowCount(), "\n";
// Booting ReportBuilder for account 42
// 10000

A method call alone doesn't trigger initialization — only one that actually reads or writes state does. rowCount() touches $this->dataset, so the initializer runs first, then the method executes against the now-initialised object. If the initializer throws, PHP reverts the object to its lazy state so you never expose a half-built instance. This is conceptually the same lazy-evaluation win you get from Laravel's lazy collections when streaming large datasets — defer the cost until you genuinely need the data.

Switch to a lazy proxy when identity matters#

Use a lazy proxy when you don't control instantiation — for example, when a factory or another library produces the real instance. newLazyProxy() takes a factory that returns a fully built, non-lazy instance instead of initialising in place. After the first state access, the proxy forwards every property read and write to that real instance.

$report = $reflector->newLazyProxy(function (ReportBuilder $object) use ($accountId): ReportBuilder {
    // Return the REAL instance — the proxy forwards to it after this point.
    return new ReportBuilder($accountId);
});

The catch is in the name: the proxy and the real instance are distinct identities. spl_object_id($report) is not the id of the instance your factory returned, and a === comparison between the two will be false. If anything keys an SplObjectStorage or a cache on object identity, a proxy will surprise you. Ghosts don't have this problem because they initialise themselves in place — same object, same id. Reach for a proxy only when you must.

Wire a lazy object into the Laravel container#

Here's the honest framing: Laravel's container is already lazy at the resolution level. app()->make() doesn't construct anything until you ask for it. The gap lazy objects fill is narrower — when a dependency is resolved eagerly because it's constructor-injected into a controller or another service, but its internal setup is only needed on some branches. Bind a lazy ghost and the container hands back the instance instantly, deferring the real construction until a method touches state.

namespace App\Providers;

use App\Services\ReportBuilder;
use Illuminate\Support\ServiceProvider;
use ReflectionClass;

final class ReportServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(ReportBuilder::class, function ($app) {
            $reflector = new ReflectionClass(ReportBuilder::class);

            return $reflector->newLazyGhost(function (ReportBuilder $report) use ($app): void {
                // Deferred until the resolving code first touches $report.
                $report->__construct($app['request']->integer('account'));
            });
        });
    }
}

A controller can now type-hint ReportBuilder and only pay the construction cost on the routes that actually build a report. This pairs well with other "do the work later" patterns like deferring work until after the response — same instinct, different layer of the request.

Gotchas and edge cases#

The biggest limitation is scope: you can only make lazy instances of user-defined classes and stdClass. Internal classes like PDO, ArrayObject, or SplStack aren't supported, so you can't wrap a raw database handle this way — wrap your own service that holds it instead.

Readonly properties need care. A fresh lazy ghost is fine: its properties are uninitialised, so the initializer's __construct() sets them on the first (and only) write. The trap is resetAsLazyGhost() on an object whose readonly properties are already initialised — that throws Cannot modify readonly property unless you pass the ReflectionClass::SKIP_INITIALIZED_READONLY flag, which leaves those properties as-is. If you lean on readonly for your domain layer, the rules for immutable value objects with PHP readonly classes still apply inside the initializer. Serializers that round-trip objects (Symfony's VarExporter has hit this) can also surface Cannot indirectly modify readonly property errors on lazy instances, so test your serialization paths.

Two more worth knowing: destructors only fire if a ghost was actually initialised (or on a proxy's real instance, if one was created), and get_object_vars() does trigger initialization even though it feels like passive inspection. Reach for var_dump() when you want to look without waking the object up.

Wrapping up#

Lazy objects are a sharp tool for a specific problem: expensive, conditional construction. Default to lazy ghosts, only use proxies when identity can stay distinct, and remember the win is real only when the work is both costly and often skipped. If you're tuning hot paths, it sits nicely next to PHP 8.4 fibers for async patterns for deferring and interleaving work. And if you're hardening a shared package, pair laziness with clean API retirement using PHP 8.4's #[\Deprecated] attribute.

FAQ#

What are lazy objects in PHP 8.4?

Lazy objects are instances whose initialization is deferred until their state is first observed or modified. You create them through Reflection, attaching an initializer that PHP calls automatically the first time something reads, writes, or iterates the object. Until then the constructor never runs, so you don't pay for setup work that a given request might never need.

What is the difference between a lazy ghost and a lazy proxy?

A lazy ghost initialises in place: the initializer fills the same object (usually by calling its constructor), and afterwards it's indistinguishable from a normal instance with the same object identity. A lazy proxy instead delegates to a separate real instance returned by its factory, and forwards property access to it. Ghosts are simpler and preserve identity; use a proxy only when you don't control how the real instance is built and can tolerate the proxy and real object having different identities.

When is a lazy object initialized?

Initialization triggers on the first operation that observes or changes state: reading or writing a property, testing isset, unsetting, calling get_object_vars(), iterating with foreach, serializing, or cloning. Method calls that don't touch any property won't trigger it. A handful of operations are deliberately non-triggering, including var_dump() and casting the object to an array, which makes them safe for inspection.

Do lazy objects work with readonly properties?

Yes. For a freshly created lazy ghost the readonly properties are uninitialised, so the initializer's constructor call sets them normally on their single allowed write. The friction is with resetAsLazyGhost() on an object whose readonly properties are already set — that raises Cannot modify readonly property unless you pass ReflectionClass::SKIP_INITIALIZED_READONLY to skip them.

Are PHP 8.4 lazy objects useful in Laravel applications?

They're useful in a narrower way than you might expect, because the service container already defers resolution until make() is called. The real benefit is when a dependency is constructor-injected (and therefore resolved eagerly) but its expensive setup is only needed on some code paths. ORMs and hydration layers like Doctrine already use this pattern internally; Laravel's container doesn't bind lazy objects natively yet, but you can do it yourself in a service provider.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.