Lazy Objects (PHP 8.4+)

Use PHP 8.4's native lazy object support — ghost objects and virtual proxies created via ReflectionClass::newLazyGhost() and ReflectionClass::newLazyProxy() — to defer expensive object initialization until first access, and understand the ORM and dependency-injection use cases this replaces userland lazy-loading hacks for.

Frameworks and ORMs have simulated 'lazy loading' for years with hand-rolled proxy classes, magic __get() tricks, or code generation — creating a lightweight stand-in object that looks like the real thing but only does the expensive work (a database query, a network call, a heavy computation) the moment something actually touches it. PHP 8.4 makes this a first-class, engine-level feature: lazy objects. This lesson covers the two flavors — lazy ghost objects and virtual proxies — how initialization is triggered, and where this fits in real ORM and dependency-injection code.

What a Lazy Object Is, and Why It Exists

A lazy object (PHP 8.4+) is an object that exists — you can hold a reference to it, type-check it, pass it around — before its real internal state has actually been computed or loaded. The object only does its real initialization work the first time something accesses one of its properties or calls one of its methods; PHP intercepts that first access, runs your supplied initializer, and from that point on the object behaves as if it had been fully constructed from the start.

The core motivation is performance and resource use: constructing every object a program *might* need, eagerly, at the moment its container object is constructed, wastes work when many of those objects never actually get used on a given request. A classic example is an ORM entity with a $author relation — loading a BlogPost shouldn't necessarily also run a second query to load its author immediately; most code paths that load a post never touch $post->author at all. Before PHP 8.4, ORMs solved this with hand-written proxy classes (often code-generated) that extended the real entity class and overrode every property/method to trigger loading — functional, but adding real complexity, extra generated code, and subtle bugs around instanceof/get_class() checks that a proxy could get wrong.

With native lazy objects, the object returned by newLazyGhost()/newLazyProxy() genuinely is an instance of the real class ($post->author instanceof Author is true, get_class($post->author) === Author::class is true) — the engine implements the lazy behavior internally rather than through generated subclassing, closing the gap between a lazy stand-in and the real thing.

• A lazy object exists and can be referenced/type-checked before its real state is initialized. • First property access or method call triggers the initializer; the object then behaves as fully initialized. • Solves the 'eagerly constructing objects that may never be used' waste, common in ORMs and DI containers. • Unlike hand-rolled proxy subclasses, a native PHP 8.4+ lazy object is genuinely an instance of the real class — instanceof and get_class() behave correctly.

Lazy Ghost Objects — ReflectionClass::newLazyGhost()

A lazy ghost object (PHP 8.4+), created with ReflectionClass::newLazyGhost(Closure $initializer), is an instance of the target class itself — not a separate wrapper object — whose properties simply aren't populated yet. You supply an initializer closure that receives the (still-uninitialized) object and is responsible for setting its properties directly, typically by calling the object's real constructor logic or assigning properties one by one. The first time any property is read or any method is called on the ghost, PHP runs your initializer exactly once, then proceeds with the now-populated object.

Ghost objects are the right tool when you already have (or are willing to write) the logic to populate an existing instance's properties, and you don't need a separate wrapping layer — the ghost *is* the real object, just filled in lazily. This is the more common and generally lighter-weight of the two lazy-object flavors, and it's the closest native equivalent to what most ORM 'proxy' generation was doing by hand before PHP 8.4.

A key detail: because a ghost object is literally an instance of the real class with its constructor not (necessarily) run up front, any logic that would normally live in __construct() needs to actually happen inside the initializer closure (or be deliberately skipped) — newLazyGhost() does not automatically call the real constructor for you; you decide what the initializer does, giving you full control over exactly what 'being initialized' means for that class.

• ReflectionClass::newLazyGhost(Closure $initializer) (PHP 8.4+) creates a genuine instance of the target class with deferred population. • The initializer closure receives the object itself and sets its properties directly when first accessed. • The initializer runs exactly once, on first property/method access, then the object behaves as normal from then on. • The real __construct() is not run automatically — the initializer is fully responsible for what 'initialized' means.

Virtual Proxies — ReflectionClass::newLazyProxy()

A virtual proxy (PHP 8.4+), created with ReflectionClass::newLazyProxy(Closure $factory), is subtly different: instead of populating the *same* object in place, your factory closure returns an entirely separate, fully-constructed real instance, and the proxy object transparently forwards every property access and method call to that real instance under the hood. From the outside, the proxy is indistinguishable from the real object (same class, same behavior) — but internally, all the actual state lives on a different object that the proxy delegates to.

Virtual proxies are the right tool when constructing the real object isn't something you can (or want to) do by mutating an existing shell — for example, when the real object must go through its normal constructor with real arguments, produced by a factory function or a DI container that you don't want to special-case for laziness. newLazyProxy() lets you say 'when someone actually needs this, call this factory to build the real thing, and make the proxy behave exactly like whatever comes back'.

The practical difference between the two: a ghost object *is* the real object with delayed population (one object, in place); a virtual proxy *wraps and delegates to* a separately-constructed real object (two objects, one forwarding to the other). Both are indistinguishable from a real, eagerly-constructed instance from the caller's point of view — instanceof and public API all behave normally either way — the difference is purely about which one fits how you already build the real object.

• ReflectionClass::newLazyProxy(Closure $factory) (PHP 8.4+) creates a proxy that delegates to a separately-built real instance. • The factory closure returns a fully-constructed real object; the proxy forwards all access to it after first trigger. • Use newLazyProxy() when the real object must be built via its own constructor/factory rather than populated in place. • Ghost = one object, populated in place. Proxy = two objects, one delegating to the other. Both look identical to external callers.

Initialization Triggers, Partial Initialization, and Debugging

By default, touching *any* property or calling *any* method on a lazy object triggers full initialization — there's no fine-grained 'only load the properties actually touched' behavior automatically. However, the Reflection API around lazy objects supports partial initialization in more advanced use: you can mark specific properties as already known/skipped (via ReflectionProperty::skipLazyInitialization()-style APIs) so that setting certain properties up front doesn't itself count as the trigger, letting you pre-seed an ID or a foreign key on a ghost object while everything else stays deferred — useful for an ORM that already knows an entity's primary key (because it came from a query result row) but hasn't loaded the rest of the row's columns yet.

Debugging lazy objects requires care: var_dump(), serialization, and similar introspection functions can themselves trigger initialization (since they typically read every property), so casually dumping a lazy object in a debugger session can silently do the exact expensive work you were trying to defer. Tools and debuggers that are lazy-object-aware can report an object's lazy/uninitialized state without forcing initialization, but generic tooling may not — be aware that observing a lazy object is not always free.

Object lifecycle for a lazy object has an extra state compared to a normal object: created-but-uninitialized (the reflection call has returned an object, but no property has been touched), initializing (the initializer/factory closure is currently running — recursive re-entrant access during this window is a bug to avoid, and can be actively problematic), and initialized (behaving exactly like an eagerly-constructed instance from then on). Well-designed initializers should be idempotent-safe and avoid triggering their own lazy object's initialization from within themselves.

• Default trigger: any property read/write or method call on the lazy object forces full initialization. • Partial initialization APIs let specific properties be pre-set without triggering the rest of initialization — useful for ORMs that already know a primary key. • Debugging caution: var_dump()/serialization can silently trigger initialization by reading every property. • Lifecycle: created-but-uninitialized → initializing (factory/initializer running) → initialized (behaves normally from then on).

ORM and Dependency-Injection Use Cases

The single most common real-world use case is ORM entity relations: loading a BlogPost row from the database populates the post's own columns immediately, but its $author (a related User entity) is set to a lazy ghost or proxy that only actually queries the users table the first time $post->author is touched. Code paths that render a post listing without ever showing author names never pay that extra query; code paths that do show the author pay it exactly once, on first access — this is precisely what ORMs like Doctrine have long simulated with generated proxy classes, and PHP 8.4's native support lets them drop that code generation layer in favor of the engine's own mechanism.

The second common use case is dependency injection containers: a container can hand out a lazy proxy for a service that's expensive to construct (opens a database connection pool, warms a cache, connects to an external API) but that a given request may never actually call. Instead of eagerly building every registered service on every request 'just in case', the container hands out lazy proxies for expensive services and only pays the construction cost for the ones a request's code path actually touches — directly improving request-time performance for large applications with many registered services.

Performance-wise, lazy objects trade a small amount of per-access overhead (checking whether initialization has already happened) for potentially large savings on paths that never touch the deferred state at all — the tradeoff is favorable exactly when 'often constructed, often unused' describes the object in question, which is a very common shape for ORM relations and DI-container services specifically.

• ORM relations: a related entity ($post->author) loads only when actually accessed, avoiding N+1-style eager loading on unused relations. • DI containers: expensive services (DB pools, external API clients) are handed out as lazy proxies and only truly constructed if a request path uses them. • The performance win is largest when objects are 'often created, rarely actually used' — the classic ORM-relation and DI-service shape. • This is the native PHP 8.4+ replacement for the generated-proxy-class approach long used by ORMs like Doctrine.

Creating Lazy Objects via Reflection (PHP 8.4+)

newLazyGhost() and newLazyProxy() are both PHP 8.4+ ReflectionClass methods — unavailable on 8.3 and earlier. Ghost: one object, initializer populates it directly. Proxy: two objects, factory builds a separate real one that the proxy delegates to. Both forms trigger their closure exactly once, on first property read, property write, or method call.

// Lazy GHOST: same object, populated in place on first access
$reflector = new ReflectionClass(Author::class);
$author = $reflector->newLazyGhost(function (Author $author) {
    // Runs once, on first property/method access.
    // Populate the SAME $author instance directly.
    $row = db_fetch_author(42);
    $author->id   = $row['id'];
    $author->name = $row['name'];
});

// Lazy PROXY: forwards to a separately-constructed real object
$reflector = new ReflectionClass(Author::class);
$author = $reflector->newLazyProxy(function (): Author {
    // Runs once, on first access. Returns a FULLY BUILT real object;
    // the proxy delegates everything to it from then on.
    $row = db_fetch_author(42);
    return new Author($row['id'], $row['name']);
});

Code Examples

Nothing is queried when newLazyGhost() runs — the object is created but empty. The [DB QUERY] line only prints the moment $author->name is read for the first time, proving initialization is deferred until actual access. The second read reuses the already-initialized object with no further query. Expected result: Lazy ghost created — no query has run yet. Accessing $author->name now: [DB QUERY for author 42] Grace Hopper Second access (already initialized, no new query): Grace Hopper

$author = $reflector->newLazyGhost(function (Author $author) { ... });

The factory closure builds a real ExpensiveService through its normal constructor, but that constructor doesn't run until $service->ping() is first called. get_class($service) still reports ExpensiveService::class even though the proxy is delegating internally, which is exactly the transparency native lazy objects provide over hand-rolled proxy subclasses. Expected result: Proxy created — no connection made yet. [Connecting: db-primary:5432] pong via db-primary:5432 Still a real ExpensiveService

$service = $reflector->newLazyProxy(function (): ExpensiveService { return new ExpensiveService(...); });

<?php
class Author
{
    public int $id;
    public string $name;
}

function fetchAuthorRow(int $id): array
{
    echo "  [DB QUERY for author {$id}]\n"; // simulates an expensive load
    return ['id' => $id, 'name' => 'Grace Hopper'];
}

$reflector = new ReflectionClass(Author::class);
$author = $reflector->newLazyGhost(function (Author $author) {
    $row = fetchAuthorRow(42);
    $author->id   = $row['id'];
    $author->name = $row['name'];
});

echo "Lazy ghost created — no query has run yet.\n";
echo "Accessing \$author->name now:\n";
echo $author->name . "\n";        // triggers the initializer HERE, on first access
echo "Second access (already initialized, no new query):\n";
echo $author->name . "\n";
<?php
class ExpensiveService
{
    public function __construct(private string $connectionInfo)
    {
        echo "  [Connecting: {$connectionInfo}]\n"; // simulates real, costly construction
    }

    public function ping(): string
    {
        return "pong via {$this->connectionInfo}";
    }
}

$reflector = new ReflectionClass(ExpensiveService::class);
$service = $reflector->newLazyProxy(function (): ExpensiveService {
    return new ExpensiveService('db-primary:5432'); // built via its REAL constructor
});

echo "Proxy created — no connection made yet.\n";
echo $service->ping() . "\n";      // triggers the factory here, connects, then delegates
echo get_class($service) === ExpensiveService::class ? "Still a real ExpensiveService\n" : "Different class!\n";

Lazy Object Lifecycle

Nothing expensive happens until the first real access; from that point on, the object behaves identically to one built eagerly up front.

Application
      │
      │  $author = $reflector->newLazyGhost($initializer);
      ▼
  ┌─────────────────────────┐
  │  Lazy Object             │   state: created, UNINITIALIZED
  │  (looks like a real       │   no query/work has run yet
  │  Author instance)         │
  └───────────┬─────────────┘
              │  $author->name   (property/method access)
              ▼
  ┌─────────────────────────┐
  │  Initialization Trigger   │   PHP intercepts the access
  │  runs $initializer ONCE   │
  └───────────┬─────────────┘
              ▼
  ┌─────────────────────────┐
  │  Real Object State        │   properties populated
  │  (INITIALIZED)            │   (ghost: in place / proxy: delegated-to object)
  └─────────────────────────┘
              │
              ▼  every access from here on
        behaves exactly like a normal, eagerly-built object

Important Rules

Lazy objects (ReflectionClass::newLazyGhost() / newLazyProxy()) require PHP 8.4 or newer.

Ghost objects populate the SAME instance in place; proxies delegate to a SEPARATE, fully-constructed instance.

Any property/method access triggers full initialization by default — use partial-initialization APIs when only some properties are known up front.

Be careful with var_dump()/serialization on lazy objects — generic introspection can silently force initialization.

Never let an initializer closure trigger access on its own not-yet-initialized object — that risks re-entrant initialization.

ORM Relation Loading Without Generated Proxy Classes

Before PHP 8.4, ORMs like Doctrine generated actual PHP proxy subclasses on disk (or in memory) that overrode every property and method of an entity to intercept access and trigger loading — functional, but adding a code-generation step, extra classes to keep in sync with the real entity, and edge cases around strict instanceof/get_class() checks that a generated subclass could get subtly wrong. Native lazy objects let ORM authors drop that generated-code layer entirely: ReflectionClass::newLazyGhost() produces a genuine instance of the real entity class with deferred population, with instanceof and get_class() behaving correctly by construction rather than by careful proxy-class engineering.

$author = $reflector->newLazyGhost($initializer); // ...later, while debugging... var_dump($author); // silently forces full initialization just to print it

$author = $reflector->newLazyGhost($initializer); // Check laziness without forcing it, e.g. via lazy-object-aware Reflection APIs, // instead of reaching for var_dump()/print_r() during debugging.

var_dump() (and similarly print_r(), serialization, and most generic introspection) reads every property to display it, which triggers the lazy object's initializer — so a debugging var_dump() can accidentally perform the exact expensive work the lazy object was meant to avoid, at exactly the wrong moment.

What is the key difference between a lazy ghost object and a lazy virtual proxy in PHP 8.4? A. Ghosts are for interfaces; proxies are for classes B. A ghost populates the same instance in place; a proxy delegates to a separately-constructed real instance C. Ghosts trigger on read only; proxies trigger on write only D. There is no functional difference, only naming

B. A ghost populates the same instance in place; a proxy delegates to a separately-constructed real instance — newLazyGhost() populates the SAME object instance directly via its initializer closure; newLazyProxy() has its factory closure build a completely separate real object, and the proxy forwards all access to that real object.

By default, what triggers a lazy object's initialization? A. Calling unset() on the object B. Any property read/write or method call on the object C. Only an explicit call to a method named initialize() D. The end of the PHP request, via a shutdown function

B. Any property read/write or method call on the object — By default, any property access (read or write) or method call on a lazy object triggers its initializer/factory to run exactly once, before that access completes.

Which Reflection methods create lazy objects, and what is their minimum PHP version? A. ReflectionClass::newLazyGhost() / newLazyProxy() — PHP 8.2+ B. ReflectionClass::newLazyGhost() / newLazyProxy() — PHP 8.4+ C. ReflectionObject::lazy() — PHP 8.3+ D. ReflectionClass::deferNew() — PHP 8.5+

B. ReflectionClass::newLazyGhost() / newLazyProxy() — PHP 8.4+ — Native lazy objects are created via ReflectionClass::newLazyGhost() and ReflectionClass::newLazyProxy(), both introduced in PHP 8.4 — not available on any earlier version.

A DI container needs to hand out a Logger service that opens a file handle in its constructor, but most requests never actually log anything. Sketch how you'd use ReflectionClass::newLazyProxy() to avoid opening that file handle on every request.

$reflector = new ReflectionClass(Logger::class); $logger = $reflector->newLazyProxy(function (): Logger { return new Logger('/var/log/app.log'); // file handle opened HERE, only on first real use }); // $logger can be injected everywhere; the file is only opened the first time // something actually calls a method like $logger->info(...) on it.

The factory closure should build the real Logger (with its real constructor, including the file-open side effect) only when something first calls a method on the proxy.