PHP 8.4: property hooks and deliberate lazy initialization

Use property hooks to enforce local invariants, distinguish restricted writes from immutability, and test when a lazy object actually initializes.
Put validation at the property boundary
PHP 8.4, released on 21 November 2024, introduced property hooks and native lazy objects. They address different problems: hooks control access to a value, while lazy objects postpone initialization. Neither feature requires converting every existing getter or service into a new abstraction. Start with an object whose callers already understand its contract.
Consider a quantity used by a quotation calculator. A negative value should never become valid object state, regardless of whether it arrives through construction or a later assignment. A set hook places that rule beside the property. This standalone example requires PHP 8.4 or later; it does not depend on a framework or database.
The complete files below were run on PHP 8.4.14 on 12 October 2026. Save them under the displayed filenames and run each with php followed by its path. The examples exercise language behavior without an ORM; they do not establish compatibility with your serializers or database layer.
<?php
declare(strict_types=1);
final class Quantity
{
public int $units {
set {
if ($value < 0) {
throw new InvalidArgumentException('Units must be non-negative.');
}
$this->units = $value;
}
}
public function __construct(int $units)
{
$this->units = $units;
}
}
$quantity = new Quantity(3);
try {
$quantity->units = -1;
} catch (InvalidArgumentException $error) {
echo $error->getMessage(), PHP_EOL;
}
echo $quantity->units, PHP_EOL; // 3
try {
new Quantity(-1);
throw new RuntimeException('Invalid construction was accepted');
} catch (InvalidArgumentException) {
echo "Invalid construction rejected", PHP_EOL;
}
$quantity->units = 5;
echo $quantity->units, PHP_EOL;
Keep property access predictable
The explicit reference to $this->units makes this a backed property: it stores a value. Accessing that backing value from its own hook does not recursively invoke the same hook. A computed get hook that only reads other properties can instead define a virtual property. That distinction matters to reflection, serializers, and code that expects stored fields.
Keep hooks small and local. A property read that makes an HTTP request is difficult to distinguish from an ordinary field access during review. A named method is usually clearer for operations that can time out or change external state. Test the serializers and hydrators your application actually uses before converting persistence models.
Asymmetric visibility solves a separate problem. A public private(set) property can be read by callers while restricting assignment to its declaring class. That does not make a referenced object deeply immutable. Hooks also cannot be combined with readonly properties; choose the contract before choosing the syntax.
Observe the lazy initialization boundary
ReflectionClass::newLazyGhost() creates an instance without running its constructor. The initializer receives that same instance and supplies its state when initialization is triggered. This example uses a counter rather than a database so the timing is visible and repeatable.
<?php
declare(strict_types=1);
final class Report
{
public function __construct(public string $title) {}
}
$initializations = 0;
$reflection = new ReflectionClass(Report::class);
$report = $reflection->newLazyGhost(
static function (Report $report) use (&$initializations): void {
++$initializations;
$report->__construct('Monthly summary');
}
);
echo $initializations, PHP_EOL; // 0
echo $report->title, PHP_EOL; // Monthly summary
echo $report->title, PHP_EOL; // Monthly summary
echo $initializations, PHP_EOL; // 1
Deferred work still needs an owner
A lazy object can avoid work when the object is never used. If every request reads it immediately, laziness merely moves the work elsewhere. With a real loader, exceptions can surface at the first property access instead of at construction, so callers need an intentional error boundary. Do not attach irreversible side effects to an initializer simply because the successful path runs once.
Before adopting this in an ORM or container integration, check that the library supports native lazy objects. Exercise initialization failure, serialization, and repeated reads. For hooks, cover valid construction, invalid construction, valid reassignment, and rejection without mutation. Those tests establish the behavior that matters; fewer getter methods alone is not evidence of an improvement.
A failed initializer can run again
If a lazy initializer throws, PHP restores the lazy object’s properties and leaves it uninitialized. A later triggering access can invoke the initializer again. Changes outside that object, such as this attempt counter, are not rolled back.
The first read below fails after assigning a temporary title. The second read retries and replaces that title; the third read uses the initialized object. Output is caught: first load failed, Recovered, Recovered, attempts=2. A real loader needs a deliberate retry policy: repeated property reads should not silently retry a charge or another irreversible operation.
<?php
declare(strict_types=1);
final class Report { public string $title; }
$attempts = 0;
$report = (new ReflectionClass(Report::class))->newLazyGhost(
static function (Report $report) use (&$attempts): void {
++$attempts;
$report->title = 'Incomplete';
if ($attempts === 1) {
throw new RuntimeException('first load failed');
}
$report->title = 'Recovered';
}
);
try {
echo $report->title, PHP_EOL;
} catch (RuntimeException $error) {
echo 'caught: ', $error->getMessage(), PHP_EOL;
}
echo $report->title, PHP_EOL;
echo $report->title, PHP_EOL;
echo 'attempts=', $attempts, PHP_EOL;

