PHP 8.4 Asymmetric Visibility: Public Read, Private Write Properties

PHP 8.4, released November 21 2024, ships a feature that immediately earns its keep in any domain-model-heavy codebase: asymmetric property visibility. You can now declare a property's read scope and write scope independently, in a single declaration — no boilerplate getters, no magic __set guards, no readonly trade-offs.

This article covers the full syntax, every valid modifier combination, the interaction with readonly and property hooks, and a set of patterns drawn from real Drupal and PHP application code.

The Syntax at a Glance

The general form is:

<read-visibility> <write-visibility>(set) <type> $property;

The (set) suffix marks the write modifier. A concrete example:

<?php

class Order
{
    public private(set) string $status = 'pending';

    public function ship(): void
    {
        $this->status = 'shipped'; // allowed — we're inside the class
    }
}

$order = new Order();
echo $order->status;      // "pending"  — public read works fine
$order->status = 'paid';  // Error: Cannot modify private(set) property Order::$status from outside scope

Valid Modifier Combinations

Read visibilityWrite visibilityTypical use
publicprivate(set)Read-only from outside, writable only by own class
publicprotected(set)Read-only from outside, writable by own class and subclasses
protectedprivate(set)Readable in hierarchy, writable only by own class

The rule is simple: the write visibility must not be less restrictive than the read visibility. private public(set) is a parse error.

A type declaration is mandatory. Attempting asymmetric visibility on an untyped property raises a fatal error at compile time.

Asymmetric Visibility vs readonly

readonly properties can be written exactly once after initialisation; after that the property is immutable for the object's lifetime. Asymmetric visibility is more flexible: you can write to the property multiple times from the allowed scope.

<?php

// readonly — one write, ever
class ImmutablePoint
{
    public function __construct(
        public readonly float $x,
        public readonly float $y,
    ) {}
}

// asymmetric — controlled mutation allowed
class MutablePoint
{
    public private(set) float $x;
    public private(set) float $y;

    public function __construct(float $x, float $y)
    {
        $this->x = $x;
        $this->y = $y;
    }

    public function translate(float $dx, float $dy): void
    {
        $this->x += $dx; // fine — mutating from within the class
        $this->y += $dy;
    }
}

If you want true immutability, use readonly. If you need state that changes through controlled methods, asymmetric visibility is the right tool.

Constructor Promotion Works Too

Promoted constructor parameters support the asymmetric syntax directly:

<?php

class Product
{
    public function __construct(
        public private(set) string $sku,
        public private(set) string $name,
        public private(set) float  $price,
    ) {}

    public function applyDiscount(float $percent): void
    {
        $this->price = round($this->price * (1 - $percent / 100), 2);
    }
}

$p = new Product('ABC-1', 'Widget', 29.99);
echo $p->sku;   // "ABC-1"
$p->price = 0;  // Fatal: Cannot modify private(set) property Product::$price

This is a clean replacement for the classic DTO pattern that required a getter for every field.

Inheritance and protected(set)

protected(set) is the bridge for class hierarchies. The base class exposes a public read surface, but writing is restricted to the class and its children:

<?php

class Entity
{
    public protected(set) int $id = 0;

    protected function setId(int $id): void
    {
        $this->id = $id;
    }
}

class User extends Entity
{
    public function __construct(int $id, public string $email)
    {
        $this->id = $id; // allowed — User extends Entity
    }
}

$user = new User(42, 'lars@example.com');
echo $user->id; // 42
$user->id = 99; // Fatal: Cannot modify protected(set) property Entity::$id

Interaction with Property Hooks (PHP 8.4)

PHP 8.4 also introduced property hooks. You can combine asymmetric visibility with a set hook to apply validation logic while still exposing the property publicly:

<?php

class Invoice
{
    public private(set) float $total = 0.0 {
        set(float $value) {
            if ($value < 0) {
                throw new \InvalidArgumentException('Total cannot be negative.');
            }
            $this->total = $value;
        }
    }

    public function addLine(float $amount): void
    {
        $this->total += $amount; // triggers the set hook
    }
}

$inv = new Invoice();
$inv->addLine(99.50);
echo $inv->total; // 99.5

The set hook runs for all writes regardless of scope — including writes from within the class itself.

Drupal Value Object Example

In Drupal 11 services or value objects passed between layers, asymmetric visibility cleans up the architecture considerably:

<?php

namespace Drupal\my_module\ValueObject;

final class NodeMetadata
{
    public private(set) int    $nid;
    public private(set) string $title;
    public private(set) string $langcode;
    /** @var string[] */
    public private(set) array  $tags = [];

    public static function fromNode(\Drupal\node\NodeInterface $node): self
    {
        $obj = new self();
        $obj->nid      = (int) $node->id();
        $obj->title    = $node->label();
        $obj->langcode = $node->language()->getId();

        foreach ($node->get('field_tags') as $item) {
            $obj->tags[] = $item->entity->label();
        }

        return $obj;
    }
}

Downstream code (controllers, template preprocessors) reads $meta->nid, $meta->title freely. No public setter is ever exposed. No __get magic required.

Caveats and Gotchas

  • Type required. Untyped properties cannot use asymmetric visibility — PHP throws a compile-time fatal.
  • Interfaces cannot declare write visibility. Interface property declarations support only read visibility; the implementing class controls the write scope.
  • Cloning copies the full state. A cloned object has the same write-scope rules. Mutating cloned properties still requires the right scope.
  • Static properties are not supported. The feature applies to instance properties only.
  • Serialisation. Both serialize() / unserialize() and __sleep / __wakeup bypass visibility as usual; deserialization can populate private(set) properties.

Checklist: When to Reach for Asymmetric Visibility

  • You have a property that external code should read freely but must not write directly.
  • You want to avoid a getter method for every field but also want to avoid readonly because the property changes over time.
  • You need a class hierarchy where child classes manage state but external code only reads it.
  • You are building a value object or DTO from an external source (database row, API response) and want to freeze it after construction.
  • You are adding a validation set hook and want to enforce that validation even for internal mutations.

Summary

Asymmetric property visibility is one of the most practical additions PHP has received in years. It eliminates entire categories of boilerplate — getter-only methods, __get overloads, internal setter helpers — while keeping the class interface clean. Adopt it in new code immediately; retrofit existing DTOs and value objects as you touch them. The performance cost is zero: asymmetric visibility is a compile-time constraint with no runtime overhead.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.