skip to content

In PHP 8.4 and later, how do lazy ghosts from newLazyGhost() differ from lazy proxies from newLazyProxy(), and when would a container choose each?

level: seniorimportance: nice to knowfreq 18%

answer

  1. both created via ReflectionClass
  2. ghost: initializer fills the same object
  3. proxy: factory returns a separate real instance
  4. state access triggers initialization
  5. user classes and stdClass only

basics

~20 s

A lazy ghost is initialized in place: its initializer fills the same object. A lazy proxy's factory returns a separate real instance the proxy forwards to, so identities differ; proxies suit objects someone else constructs.

solid answer

~50 s

Both arrived in PHP 8.4 on `ReflectionClass`: `newLazyGhost(callable $initializer, int $options = 0)` and `newLazyProxy(callable $factory, int $options = 0)`. Neither calls the constructor up front; the first operation that observes or modifies state — reading or writing a property, `isset`, `foreach` over properties, `serialize()`, `clone`, `get_object_vars()` — triggers initialization, while method calls that touch no state do not. A **ghost's initializer** receives the object and must initialize it in place (typically `$object->__construct(...)`), returning nothing. A **proxy's factory** returns a *different* real instance; afterwards every property access on the proxy is forwarded, but `$proxy === $real` is false. Ghosts fit when the container both creates and initializes the object; proxies fit when a factory or third-party code builds it. Both work on final classes, but only on user classes and `stdClass`, not other internal classes. If the initializer throws, the object reverts to lazy.

code

php · 20 lines
php
<?php
declare(strict_types=1);

final class ReportMailer
{
    public function __construct(public string $dsn) { echo "connecting\n"; }
}

$ref = new ReflectionClass(ReportMailer::class);

$ghost = $ref->newLazyGhost(function (ReportMailer $m): void {
    $m->__construct('smtp://mail.example.test');   // initialize in place
});

$proxy = $ref->newLazyProxy(fn (ReportMailer $p): ReportMailer
    => new ReportMailer('smtp://mail.example.test'));  // separate real instance

var_dump($ref->isUninitializedLazyObject($ghost));  // bool(true)
echo $ghost->dsn, PHP_EOL;   // connecting, then the DSN
echo $proxy->dsn, PHP_EOL;   // connecting, then the DSN, read from the real instance

go deeper

for a junior

Know that PHP 8.4 can create objects whose construction is postponed until they are actually used.

for a middle

Name the two factory methods, what the ghost initializer and proxy factory must return, and which operations trigger initialization.

for a senior

Choose ghosts or proxies for container services and ORM entities based on who constructs the object and whether identity matters, and handle initializer failures.

for a principal

Evaluate replacing code-generated proxies with native lazy objects, weighing removed build tooling against the PHP 8.4 minimum and behavioural differences.

## Why PHP added lazy objects Containers and ORMs have long wanted to hand out an object *now* and pay for building it *later*, for example a mailer service that most requests never use, or an entity whose row has not been loaded yet. Before PHP 8.4 this required generating a subclass that overrides every method, which fails for `final` classes and adds code generation. PHP 8.4 moved the mechanism into the engine through two `ReflectionClass` methods. ## Creating them - `ReflectionClass::newLazyGhost(callable $initializer, int $options = 0): object` - `ReflectionClass::newLazyProxy(callable $factory, int $options = 0): object` - `resetAsLazyGhost()` / `resetAsLazyProxy()` turn an existing object lazy again. Neither runs the constructor at creation. `var_dump()` shows `lazy ghost object(Example)` or `lazy proxy object(Example)` with uninitialized properties, and `get_class()` returns the real class name. They work on any user-defined class, including `final` ones, and on `stdClass`. Other internal classes, and user classes extending them, throw `Error: Cannot make instance of internal class lazy`. ## What triggers initialization The engine initializes the object just before an operation that observes or modifies its state: - reading, writing, `isset()` or `unset()` on a property; - `ReflectionProperty::getValue()`/`setValue()`; - `get_object_vars()`, `foreach` over properties, `serialize()`, `json_encode()`; - `clone`. Method calls that never touch state do **not** trigger it. Some operations are explicitly non-triggering: `var_dump()`, casting to array, `ReflectionProperty::skipLazyInitialization()` and `setRawValueWithoutLazyInitialization()` (useful for pre-setting an ID), and `serialize()` when the `ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE` option was passed. ## Ghosts and proxies compared | Aspect | Lazy ghost | Lazy proxy | |---|---|---| | Callback | initializer, receives the object, returns nothing | factory, receives the proxy, returns the real instance | | Where state lives | in the lazy object itself | in the separate real instance | | After initialization | indistinguishable from a normal object | forwards every property access to the real instance | | Identity | one object | proxy and real instance are different objects | | Constructor | you call `$obj->__construct(...)` in the initializer | the factory builds the real instance however it likes | | Best when | you control creation and initialization | a factory or other code creates the object | The proxy's real instance must be of the same class, or of a parent class when the proxy's class adds no properties and does not override `__destruct` or `__clone`. ## Failure and lifecycle rules 1. **Exceptions roll back.** If the initializer or factory throws, the object's state is reverted and it is marked lazy again, so no half-initialized object escapes. 2. **Destructors.** A ghost's destructor runs only if it was initialized; for a proxy, only the real instance's destructor runs. 3. **Cloning** initializes first; for a proxy both proxy and real instance are cloned. 4. **Introspection.** `ReflectionClass::isUninitializedLazyObject()`, `initializeLazyObject()` and `markLazyObjectAsInitialized()` let framework code inspect or force the state. ## Pitfalls - **Identity checks on proxies.** Code that stores `$this` during construction — registering itself in a map, for instance — stores the real instance, while callers hold the proxy; `===` between them is `false`. - **Debug output.** `var_dump()` does not initialize, so dumping a lazy object shows uninitialized properties; that is expected, not a bug. - **Eager serialization.** `serialize()` initializes the object unless `SKIP_INITIALIZATION_ON_SERIALIZE` was passed, which can trigger an expensive load inside a cache write. ## Choosing in a container - A service the container builds itself from autowired arguments → **ghost**: the initializer calls the constructor with the resolved dependencies, and the service object is the one everyone holds. - A service produced by a user-supplied factory or a third-party builder → **proxy**: the container cannot construct the object in place, so it lets the factory return it. - Code that compares identity (`===`, `spl_object_id()`, `SplObjectStorage`) or registers the object somewhere during construction → prefer **ghost**, since a proxy and its real instance are two objects.

  • How do you set an entity's ID on a PHP lazy ghost without triggering the database load?
    Use `ReflectionProperty::setRawValueWithoutLazyInitialization($ghost, 42)`, or call `skipLazyInitialization()` and then `setValue()`. That property is marked non-lazy, so reading `$ghost->id` later does not initialize the object, while any other property access still does.
  • What happens if a lazy object's initializer throws an exception in PHP 8.4+?
    The object's own state is reverted and it is marked lazy again, so the next access retries initialization. Side effects on other objects are not undone. This prevents a partially initialized object from being observed.

saying these in an interview costs you the question

  • A lazy proxy becomes the real instance once initialized, so === matches.
  • Any method call on a lazy object triggers initialization.
  • Lazy objects need a generated subclass, so final classes cannot be lazy.
  • Every class, including internal ones like DateTime, can be made lazy.
  • newLazyGhost() runs the constructor immediately and defers only property reads.