PHP 8.4 Asymmetric Visibility and Lazy Objects: Eliminating Boilerplate Forever

PHP 8.4 Asymmetric Visibility and Lazy Objects: Eliminating Boilerplate Forever

The Boilerplate Problem Asymmetric Visibility Destroys

Every PHP developer has written this pattern hundreds of times: a class with private properties, public getter methods that return them, and no setter — because the property should be readable externally but only writable internally. Before PHP 8.4, this required 5–10 lines of ceremony per property: declare it private, write a getter method, add a docblock for IDE support. Multiply by 8 properties in a typical DTO or value object, and you have 60+ lines of pure boilerplate that communicate one idea: “read externally, write internally.”

PHP 8.4’s asymmetric visibility collapses this into a single declaration. The syntax is intuitive: the read visibility comes first (as with any property), followed by the write visibility in parentheses using the (set) modifier.

// BEFORE PHP 8.4: The boilerplate tax
class UserProfile
{
    private string $name;
    private string $email;
    private DateTimeImmutable $createdAt;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
        $this->createdAt = new DateTimeImmutable();
    }

    public function getName(): string { return $this->name; }
    public function getEmail(): string { return $this->email; }
    public function getCreatedAt(): DateTimeImmutable { return $this->createdAt; }
}

// AFTER PHP 8.4: Asymmetric visibility
class UserProfile
{
    public private(set) DateTimeImmutable $createdAt;

    public function __construct(
        public private(set) string $name,
        public private(set) string $email,
    ) {
        $this->createdAt = new DateTimeImmutable();
    }
}

// Usage — reading works, writing from outside throws
$profile = new UserProfile('Alice', '[email protected]');
echo $profile->name;       // 'Alice' — public read works
echo $profile->email;      // '[email protected]'
// $profile->name = 'Bob'; // Error: Cannot modify private(set) property

Three properties, zero getter methods, zero docblocks for return types (the property’s type declaration serves as documentation). The resulting class is 12 lines instead of 25.

Visibility Modifier Rules and Combinations

The (set) modifier accepts private, protected, or public. The fundamental rule: the write visibility must be equal to or more restrictive than the read visibility. This constraint prevents nonsensical declarations like “private to read but public to write.”

DeclarationExternal ReadExternal WriteSubclass WriteInternal Write
public private(set)
public protected(set)
protected private(set)
public public(set)✅ (same as public)

The public protected(set) combination deserves attention for domain-driven designs. It exposes properties for reading throughout the application while restricting mutations to the class hierarchy — perfect for aggregate roots where child entities need write access but application services should only read.

// Domain model with protected(set) for hierarchy-only mutations
class Order
{
    public protected(set) string $status = 'pending';
    public protected(set) float $total = 0.0;

    /** @var OrderLine[] */
    public private(set) array $lines = [];

    public function addLine(string $product, float $price, int $qty): void
    {
        $this->lines[] = new OrderLine($product, $price, $qty);
        $this->recalculate();
    }

    private function recalculate(): void
    {
        $this->total = array_reduce(
            $this->lines,
            fn(float $sum, OrderLine $line) => $sum + $line->subtotal(),
            0.0
        );
    }
}

class PriorityOrder extends Order
{
    public function expedite(): void
    {
        // protected(set) allows subclass writes
        $this->status = 'expedited';
        $this->total *= 1.15; // 15% express surcharge
    }
}

Combining Asymmetric Visibility with Property Hooks

PHP 8.4 also introduced property hooks, which intercept get and set operations on properties. When combined with asymmetric visibility, you get fine-grained access control with validation — without any getter/setter methods.

class Temperature
{
    // Publicly readable in Celsius; privately writable with validation
    public private(set) float $celsius {
        set(float $value) {
            if ($value celsius = $value;
        }
    }

    // Virtual property: computed from celsius, no storage
    public float $fahrenheit {
        get => ($this->celsius * 9 / 5) + 32;
    }

    // Virtual property: computed from celsius, no storage
    public float $kelvin {
        get => $this->celsius + 273.15;
    }

    public function __construct(float $celsius)
    {
        $this->celsius = $celsius; // Triggers the set hook
    }
}

$temp = new Temperature(100);
echo $temp->celsius;     // 100
echo $temp->fahrenheit;  // 212
echo $temp->kelvin;      // 373.15
// $temp->celsius = -300; // Throws InvalidArgumentException

The $fahrenheit and $kelvin properties are virtual — they have no backing storage, consuming zero memory per instance. They are computed on access. Combined with the validated set hook on $celsius, the class enforces domain invariants without a single explicit method.

Lazy Objects: Deferred Initialization for Expensive Dependencies

PHP 8.4’s lazy objects address a different but equally pervasive problem: initializing expensive objects before they are actually needed. Database connections, HTTP clients, file handles, and large data structures often get created in constructors even when only a fraction of requests will use them.

The ReflectionClass::newLazyProxy() and ReflectionClass::newLazyGhost() methods create objects that defer their initialization until the first property access or method call. This is not a userland lazy-loading wrapper — it is a core engine feature that creates genuine instances of the target class, making instanceof checks, type hints, and method calls work transparently.

class HeavyReportGenerator
{
    private PDO $db;
    private array $cachedMetrics;

    public function __construct()
    {
        // Expensive: opens DB connection, loads 50MB of metrics
        $this->db = new PDO('pgsql:host=db.internal;dbname=analytics');
        $this->cachedMetrics = $this->loadMetrics();
        echo "HeavyReportGenerator initialized (expensive!)\n";
    }

    private function loadMetrics(): array
    {
        // Simulates loading large dataset
        return range(1, 1_000_000);
    }

    public function generateMonthlyReport(): string
    {
        return 'Report with ' . count($this->cachedMetrics) . ' data points';
    }
}

// Create a lazy proxy — constructor does NOT run yet
$reflector = new ReflectionClass(HeavyReportGenerator::class);
$generator = $reflector->newLazyProxy(function () {
    // This factory runs ONLY on first access
    return new HeavyReportGenerator();
});

// At this point: zero DB connections, zero memory for metrics
echo $generator instanceof HeavyReportGenerator; // true (!)
echo "Generator created, but not initialized yet\n";

// First actual method call triggers initialization
echo $generator->generateMonthlyReport();
// Output: "HeavyReportGenerator initialized (expensive!)"
// Output: "Report with 1000000 data points"

Ghost Objects vs Proxy Objects

newLazyProxy() wraps a factory function that returns a fully initialized instance. Property accesses are forwarded to the backing instance. newLazyGhost() initializes the object in place — the factory receives the ghost object itself and populates its properties directly. Ghosts are more memory-efficient (no wrapper object) but require the factory to know the class internals.

// Ghost object: initialized in-place, no wrapper
$ghost = $reflector->newLazyGhost(function (HeavyReportGenerator $obj) {
    // Manually initialize the object's state
    // The $obj IS the final instance — no proxy wrapper
    $obj->__construct();
});

// Check initialization state
echo ReflectionClass::isUninitializedLazyObject($ghost); // true
$ghost->generateMonthlyReport(); // Triggers initialization
echo ReflectionClass::isUninitializedLazyObject($ghost); // false

Lazy objects integrate naturally with dependency injection containers. When a container wires up a service graph, many services may never be called during a given request. Wrapping them as lazy proxies means only the services that actually handle the request pay their initialization cost. The advanced dependency injection patterns in PHP 8.4 complement this nicely — autowired lazy proxies combine the ergonomics of constructor injection with the performance of lazy initialization.

Practical Impact: Before-and-After Comparisons

The combined effect of asymmetric visibility, property hooks, and lazy objects reshapes how PHP classes look in practice. Here is a realistic API response DTO that demonstrates the cumulative boilerplate reduction:

// PHP 8.4: Complete API response DTO — zero getters, validated, immutable-from-outside
final class ApiResponse
{
    public private(set) DateTimeImmutable $respondedAt;

    public function __construct(
        public private(set) int $statusCode,
        public private(set) string $body,
        public private(set) array $headers = [],
    ) {
        $this->respondedAt = new DateTimeImmutable();
    }

    // Virtual property: computed on access
    public bool $isSuccess {
        get => $this->statusCode >= 200 && $this->statusCode  json_decode($this->body, true, 512, JSON_THROW_ON_ERROR);
    }

    public string $contentType {
        get => $this->headers['content-type'] ?? 'application/octet-stream';
    }
}

$response = new ApiResponse(200, '{"users": []}', ['content-type' => 'application/json']);
echo $response->statusCode;   // 200
echo $response->isSuccess;    // true
echo $response->contentType;  // 'application/json'
var_dump($response->json);    // ['users' => []]

This class would have required approximately 80 lines of code in PHP 8.3: 4 private properties, a constructor, 4 getter methods, 3 computed-property methods, and associated docblocks. In PHP 8.4, it is 25 lines with better type safety and enforced immutability from the caller’s perspective.

Asymmetric Visibility with readonly and Promoted Properties

PHP 8.4’s readonly modifier interacts naturally with asymmetric visibility. A readonly property can only be written once — typically in the constructor. Adding private(set) to a readonly property is redundant: readonly already restricts writes to initialization. But public protected(set) readonly is meaningful — it allows subclass constructors to initialize the property while keeping it publicly readable and immutable after construction.

// Asymmetric visibility with readonly — inheritance-aware immutability
class BaseEntity
{
    public protected(set) readonly string $id;
    public protected(set) readonly DateTimeImmutable $createdAt;

    public function __construct(string $id)
    {
        $this->id = $id;
        $this->createdAt = new DateTimeImmutable();
    }
}

class Product extends BaseEntity
{
    public function __construct(
        string $id,
        public private(set) readonly string $sku,
        public private(set) float $price,
    ) {
        parent::__construct($id);
    }

    public function applyDiscount(float $percent): void
    {
        // $this->sku = 'new'; // Error: readonly — cannot modify after construction
        $this->price *= (1 - $percent / 100); // Works: price is not readonly
    }
}

$product = new Product('p-001', 'SKU-12345', 99.99);
echo $product->id;    // 'p-001' — public read works
echo $product->sku;   // 'SKU-12345'
// $product->id = 'x'; // Error: readonly
// $product->sku = 'y'; // Error: readonly + private(set)

New Array Functions in PHP 8.4

While not directly related to visibility, PHP 8.4 also shipped four array functions that complement asymmetric visibility by reducing the amount of inline logic in methods that process property arrays. array_find(), array_find_key(), array_any(), and array_all() replace common foreach + conditional patterns.

class ShoppingCart
{
    /** @var CartItem[] */
    public private(set) array $items = [];

    public function addItem(CartItem $item): void
    {
        $this->items[] = $item;
    }

    // array_find: return the first matching element, or null
    public function findByProduct(string $productId): ?CartItem
    {
        return array_find(
            $this->items,
            fn(CartItem $item) => $item->productId === $productId
        );
    }

    // array_any: check if at least one item matches
    public function hasExpensiveItems(float $threshold = 100.0): bool
    {
        return array_any(
            $this->items,
            fn(CartItem $item) => $item->price > $threshold
        );
    }

    // array_all: check if ALL items match
    public function allInStock(): bool
    {
        return array_all(
            $this->items,
            fn(CartItem $item) => $item->inStock
        );
    }
}

// CartItem itself uses asymmetric visibility
final class CartItem
{
    public function __construct(
        public private(set) string $productId,
        public private(set) float $price,
        public private(set) bool $inStock = true,
    ) {}
}

The combination of public private(set) array properties with array_find() and array_any() eliminates the getter-plus-helper-method pattern entirely. External code reads $cart->items directly for display purposes, while mutation logic stays encapsulated inside the class. As documented in the PHP manual for array_find(), these functions return early on the first match — they do not process the entire array when only a boolean or first-match result is needed.

Migration Strategy and Framework Compatibility

Adopting these features incrementally is straightforward. Start with DTOs and value objects — classes where the getter-only pattern is most prevalent. Replace private properties plus getter methods with public private(set) promoted constructor parameters. Run your test suite. The external API (reading $obj->name versus $obj->getName()) changes, so update call sites — but IDE refactoring tools handle this mechanically.

For lazy objects, identify constructor-heavy services in your DI container. Symfony 7.2+ and Laravel 12+ both support lazy proxy generation natively. If you use the event-driven PHP patterns with RoadRunner where workers persist across requests, lazy initialization prevents unused services from consuming memory across the worker’s entire lifecycle.

One edge case to watch: serialization. Asymmetric visibility properties serialize normally (the serializer reads the public property), but lazy objects that have not been initialized will trigger initialization during serialization. If you are caching unresolved lazy objects, you will inadvertently trigger their expensive construction during the cache write. Test serialization paths explicitly when introducing lazy proxies into cached service graphs.

Another consideration is static analysis tooling. PHPStan 2.x and Psalm 6.x both support asymmetric visibility type inference — they correctly narrow the writable scope in their control flow analysis. If you are using PHPStan or Psalm for static analysis, update to the latest rule sets to get accurate diagnostics for the new visibility modifiers. Older rule versions may report false positives on private(set) property access patterns.

For teams maintaining backward compatibility with PHP 8.3, asymmetric visibility and lazy objects are strictly additive features — they do not change existing behavior. You can introduce them in new code while leaving existing classes untouched, and gradually migrate as your minimum PHP version requirement advances.

PHP 8.4’s asymmetric visibility and lazy objects represent the language’s most impactful OOP improvements since typed properties in PHP 7.4. They remove boilerplate without adding complexity — a rare combination that makes codebases simultaneously shorter, safer, and more expressive.

editor's pick

latest video

news via inbox

Nulla turp dis cursus. Integer liberos  euismod pretium faucibua

Leave A Comment