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?
answer
- both created via ReflectionClass
- ghost: initializer fills the same object
- proxy: factory returns a separate real instance
- state access triggers initialization
- user classes and stdClass only
basics
~20 sA 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 sBoth 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
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 instancego deeper
Know that PHP 8.4 can create objects whose construction is postponed until they are actually used.
Name the two factory methods, what the ghost initializer and proxy factory must return, and which operations trigger initialization.
Choose ghosts or proxies for container services and ORM entities based on who constructs the object and whether identity matters, and handle initializer failures.
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.