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 visibility | Write visibility | Typical use |
|---|---|---|
public | private(set) | Read-only from outside, writable only by own class |
public | protected(set) | Read-only from outside, writable by own class and subclasses |
protected | private(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/__wakeupbypass 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
readonlybecause 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
sethook 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.