skip to content

In PHP 8.4 and later, what does asymmetric visibility such as public private(set) do, and how does it differ from readonly?

level: middleimportance: should knowfreq 30%

answer

  1. separate scope for reading and writing
  2. set scope never wider than get
  3. typed properties only
  4. array appends count as writes
  5. private(set) implies final

basics

~20 s

Since PHP 8.4, a property can have separate read and write visibility: public private(set) lets anyone read it but only the class write it, as often as it likes. readonly instead allows a single write ever.

solid answer

~40 s

PHP 8.4 added **asymmetric visibility**: `public private(set) int $seatsLeft` is readable everywhere and writable only inside its class; `public protected(set)` also lets subclasses write. `private(set)` alone is shorthand for `public private(set)`. Unlike `readonly`, the owning scope can write the property **any number of times**, so it suits mutable state that must change only through the class's methods. Rules: the property must be **typed**, the set visibility cannot be wider than the read visibility (`private protected(set)` is rejected), a `private(set)` property is **implicitly final**, and taking a reference or modifying an array element counts as a **write**, so outside code calling `$b->legs[] = $leg` gets `Cannot indirectly modify private(set) property ...`. Readonly is effectively a write-once property with `protected(set)` since 8.4, and the two can be combined. PHP 8.5 extended set visibility to **static** properties.

code

php · 24 lines
php
<?php
declare(strict_types=1);

final class Itinerary
{
    public private(set) array $legs = [];

    public function addLeg(string $flightNo): void
    {
        $this->legs[] = $flightNo;     // allowed inside the class
    }
}

$it = new Itinerary();
$it->addLeg('LH400');

try {
    $it->legs[] = 'LH401';         // an append is a write
} catch (Error $e) {
    echo $e->getMessage(), PHP_EOL;
    // Cannot indirectly modify private(set) property Itinerary::$legs from global scope
}

echo count($it->legs), PHP_EOL;    // 1

go deeper

for a junior

Recall that public private(set) means everyone can read the property but only its class can change it.

for a middle

Explain the rules: typed only, set scope not wider than read, private(set) implies final, and array writes and references count as writes.

for a senior

Choose between asymmetric visibility, readonly and getters for a class's state, and remember objects behind the property remain mutable.

for a principal

Set guidance on exposing state through properties versus methods across teams, considering API stability when classes are extended.

## The feature Before PHP 8.4 a property had one visibility for both reading and writing. Exposing a value read-only meant a private property plus a getter method. **Asymmetric visibility** lets the declaration say it directly: ```php final class Flight { public function __construct( public private(set) int $seatsLeft, ) {} public function reserve(int $n): void { if ($n > $this->seatsLeft) { throw new DomainException('Not enough seats'); } $this->seatsLeft -= $n; // allowed: class scope } } ``` Outside code reads `$flight->seatsLeft` like any public property; `$flight->seatsLeft = 99` throws `Error: Cannot modify private(set) property Flight::$seatsLeft from global scope`. ## The rules - **Syntax**: `{read visibility} {set visibility}(set)`. When the read visibility is public it may be omitted: `private(set) int $x` equals `public private(set) int $x`. No spaces inside: `private( set )` is a parse error. - **Set is never wider than read**: `public protected(set)` and `protected private(set)` are fine; `private protected(set)` fails with `Visibility of property ... must not be weaker than set visibility`. - **Typed properties only**: an untyped property with a set visibility fails with `Property with asymmetric visibility ... must have type`. - **`private(set)` implies `final`**: a subclass cannot redeclare the property. - **References and array writes follow the set visibility**, because both can modify the value. Outside code cannot do `$f->legs[] = $leg` or `$r = &$f->legs` on a `private(set)` array: `Cannot indirectly modify private(set) property ...`. - **Statics**: PHP 8.5 allows set visibility on static properties too (`public private(set) static int $calls`). ## Compared with readonly | | `public private(set)` | `public readonly` | |---|---|---| | who may write | the class (or subclasses with `protected(set)`) | the class and subclasses (implicit `protected(set)` since 8.4) | | how many writes | any number | one initializing write | | default value allowed | yes | no | | property hooks allowed | yes | no | | typical use | encapsulated mutable state | immutable value objects | The two combine: `public private(set) readonly string $pnr` is write-once and initializable only by the declaring class. ## Compared with getters A getter method (`seatsLeft(): int`) gives the same read-only view but adds a method per field and a call syntax that differs from a property. Asymmetric visibility keeps property syntax and lets tools see a real typed property. When reading needs **logic**, not just access control, a property **hook** is the matching tool, and the two can be combined on one property. ## Replacing a getter A common refactoring in PHP 8.4 codebases: 1. Before: `private int $seatsLeft;` plus `public function getSeatsLeft(): int`. 2. After: `public private(set) int $seatsLeft;`, and callers read `$flight->seatsLeft`. 3. Keep mutators such as `reserve()` as methods, because they carry validation and intent. The change is visible to callers (a method call becomes a property read), so in a library it is an API change. Inside an application it removes a method per field without losing the write protection. If validation must run on every write, add a `set` hook to the same property instead of returning to setter methods. ## Pitfalls 1. Objects in a `private(set)` property are still mutable through their own methods; set visibility guards the slot, not the object. 2. A `public private(set) array` exposes the whole array for reading; callers get a copy, but large arrays are still exposed data. 3. Widening in subclasses is allowed where the parent is not final: a child may redeclare `public protected(set)` as `public`, which reopens writes. Use `private(set)` or `final` when that must not happen. ## Summary Asymmetric visibility separates who may read from who may write a typed property. It allows repeated writes from the owning scope, unlike readonly's single write, blocks outside array appends and references, and makes `private(set)` properties final.

  • Why is a `private(set)` property automatically final?
    If a subclass could redeclare it, the child could widen the set visibility and write a value the parent class believes only it controls. Making `private(set)` imply `final` keeps the parent's write guarantee intact, so a child class cannot redeclare the property at all.
  • Can outside code append to a `public private(set) array` property?
    No. Modifying an array element needs write access, so it follows the set visibility. The append throws `Cannot indirectly modify private(set) property ... from global scope`, while reading the whole array stays allowed. Expose a method such as `addLeg()` for controlled changes.

A museum display case works like public private(set): every visitor can look at what is inside, but only staff hold the key to rearrange or add items. A readonly property is instead a case sealed once at opening day that not even staff may reopen.

saying these in an interview costs you the question

  • private(set) makes the property write-once, just like readonly.
  • Asymmetric visibility works on untyped properties too.
  • Outside code can still append to a public private(set) array.
  • protected public(set) is valid and lets anyone write a protected property.
  • Asymmetric visibility makes the object stored in the property immutable.