PHP 8.4 — Asymmetric visibility (public private(set))
Concept
Asymmetric visibility, introduced in PHP 8.4, allows you to declare separate visibility levels for reading and writing a property. The syntax is public private(set) Type $property — read publicly, write privately. The (set) modifier can be private(set), protected(set), or any combination where the setter visibility is equal to or more restrictive than the getter visibility.
The canonical problem this solves: domain objects with properties that should be readable from anywhere but only writable from within the class itself. Before PHP 8.4, you had three options — a plain private property with a public getter method, a readonly property (write-once, no mutation ever), or a public property with no write protection at all. None of these perfectly models "publicly readable, internally mutable" — which is exactly what aggregate roots and entities need.
With public private(set), you get:
- Public read access (
$user->id,$order->status) without a getter method. - Private write access — only code inside the declaring class can assign the property.
- Mutability — unlike
readonly, you can write to it multiple times internally.
Asymmetric visibility is compatible with readonly in a specific way: readonly already implies private(set) implicitly. The feature also composes with property hooks — you can have public private(set) string $email with a set hook that validates before storing.
| Access pattern | PHP < 8.4 | PHP 8.4+ |
|---|---|---|
| Public read + no write | readonly (write-once only) | public private(set) |
| Public read + internal mutation | Private property + getter method | public private(set) |
| Protected read + private write | Not expressible | protected private(set) |
| Full public | public | public (unchanged) |
Code Example
<?php
declare(strict_types=1);
class Order
{
public private(set) string $status;
public private(set) \DateTimeImmutable $updatedAt;
public private(set) int $itemCount = 0;
/** @var list<OrderItem> */
public private(set) array $items = [];
public function __construct(
public readonly string $id,
public private(set) \DateTimeImmutable $createdAt,
) {
$this->status = 'pending';
$this->updatedAt = $createdAt;
}
public function addItem(OrderItem $item): void
{
$this->items[] = $item; // allowed — private(set) is visible inside
$this->itemCount = count($this->items);
$this->updatedAt = new \DateTimeImmutable();
}
public function confirm(): void
{
if ($this->status !== 'pending') {
throw new \LogicException("Cannot confirm a {$this->status} order");
}
$this->status = 'confirmed'; // allowed inside class
$this->updatedAt = new \DateTimeImmutable();
}
public function cancel(): void
{
if (!in_array($this->status, ['pending', 'confirmed'], true)) {
throw new \LogicException("Cannot cancel a {$this->status} order");
}
$this->status = 'cancelled';
$this->updatedAt = new \DateTimeImmutable();
}
}
class OrderItem
{
public function __construct(
public readonly string $sku,
public readonly int $quantity,
public readonly float $unitPrice,
) {}
}
$order = new Order('ORD-001', new \DateTimeImmutable());
$order->addItem(new OrderItem('SKU-42', 2, 19.99));
$order->confirm();
// Public read — works fine from anywhere
echo $order->status; // confirmed
echo $order->itemCount; // 1
// Private write — causes an Error:
// $order->status = 'shipped'; // Error: Cannot modify private(set) property from outside
// Protected asymmetric visibility — readable by child classes, writable only inside parent
class Entity
{
protected private(set) int $version = 0;
public function incrementVersion(): void
{
$this->version++; // allowed
}
}
class AuditedEntity extends Entity
{
public function getVersion(): int
{
return $this->version; // readable via protected
}
// Cannot write: $this->version = 5; // Error — private(set) means parent only
}
// Combining with property hooks
class Product
{
public private(set) string $slug {
set {
$this->slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($value)));
}
}
public function __construct(string $name)
{
$this->slug = $name; // triggers set hook, stores normalized slug
}
}
$p = new Product(' My Product Name! ');
echo $p->slug; // my-product-name-Interview Q&A
Q: How does asymmetric visibility differ from readonly and when should you choose one over the other?
readonly is write-once: a property can be assigned exactly once (in the constructor or before first read), and subsequent writes throw an Error. Asymmetric visibility (public private(set)) is write-many but write-restricted: the class can update the property as many times as needed, but external code cannot write to it at all. Use readonly for immutable value objects — coordinates, monetary amounts, identifiers — where the value is set once and never changes. Use public private(set) for entities and aggregates — orders, users, accounts — whose internal state evolves through domain operations but should never be mutated arbitrarily from outside the class boundary.
Q: Can private(set) and protected(set) be used with readonly simultaneously?
No — readonly already implies the most restrictive possible set visibility (effectively private(set) combined with write-once semantics). Declaring public readonly private(set) is redundant and a parse error. However, asymmetric visibility modifiers can be combined with property hooks and with typed properties freely. The rule is that the write visibility modifier must be equal to or more restrictive than the read visibility: public protected(set) is valid (read public, write protected), protected public(set) is a fatal error (read protected, write public would widen the set access).
Q: How does asymmetric visibility interact with child classes and inheritance?
If a parent declares public private(set) string $status, child classes can read $this->status normally but cannot write to it, because the write visibility is private — scoped to the parent class only. This is a strong encapsulation guarantee: the parent controls all transitions, and child classes must use the parent's methods to trigger state changes. If you want children to be able to write but not external code, use public protected(set), which restricts write access to the class and its descendants. This models the template method pattern cleanly — the parent defines which properties exist and who can modify them, while children interact through the inheritance hierarchy's protected surface.