Asymmetric Property Visibility (PHP 8.4+)

Use PHP 8.4's asymmetric property visibility — public private(set), public protected(set), and protected private(set) — to give a property different read and write access, replacing a large share of hand-written getter-only encapsulation.

Before PHP 8.4, giving a property public reads but restricted writes required making it private/protected and hand-writing a public getter method — the property itself couldn't express 'anyone can read this, but only I can change it'. Asymmetric property visibility, added in PHP 8.4, lets a single property declaration say exactly that: public private(set) string $email; means any code can read $email, but only code inside the declaring class can assign to it. This lesson covers the three asymmetric combinations, how they interact with inheritance, constructor promotion, readonly, and PHP 8.5's extension of the feature to static properties.

The Three Asymmetric Combinations

Asymmetric visibility (PHP 8.4+) lets a property declare one visibility for reads and a stricter one for writes, using the syntax <read-visibility> <write-visibility>(set). There are three meaningful combinations, since the write visibility must always be equal to or stricter than the read visibility: public private(set) — anyone can read, only the declaring class can write; public protected(set) — anyone can read, the declaring class and its subclasses can write; and protected private(set) — the declaring class and subclasses can read, only the declaring class itself can write.

You cannot make the write side *less* restrictive than the read side — there is no private public(set), because that would mean code outside the class can write a property it can't even read, which makes no sense and PHP rejects at compile time. If you omit the (set) visibility entirely, the property behaves exactly as it always has — both read and write share the single declared visibility (ordinary symmetric visibility), which remains the default and is unaffected by this feature.

This directly replaces a very common pattern: a private property plus a public getter and no setter, used purely to expose a read-only view of internal state to the outside world while still allowing the class itself to update it freely. public private(set) string $email; is a one-line replacement for private string $email; public function getEmail(): string { return $this->email; } — with the added benefit that it's still a real property (usable with property-based tooling, serialization, etc.), not a synthetic getter method.

• public private(set) — any code reads; only the declaring class writes. • public protected(set) — any code reads; the declaring class and subclasses write. • protected private(set) — declaring class and subclasses read; only the declaring class writes. • Write visibility must be equal to or stricter than read visibility — private public(set) is invalid and rejected at compile time. • Omitting (set) entirely keeps ordinary symmetric visibility, unchanged from pre-8.4 behavior.

Reads vs Writes: What 'Write' Actually Means

The write restriction in asymmetric visibility applies to direct property assignment from outside the permitted scope — $user->email = '[email protected]'; from outside the class fails with a visibility error if $email is public private(set). It does not restrict calling a public method on the object that happens to change internal state through legitimate means (that method itself runs inside the class, so its own writes to the property are allowed) — asymmetric visibility controls direct property syntax, not everything that could indirectly cause a change.

This means public private(set) is not the same guarantee as readonly: a readonly property can be set at most once (typically in the constructor) and never again, from anywhere, including from inside the declaring class. An asymmetric private(set) property, by contrast, can be reassigned any number of times — just only by code inside the declaring class (or class+subclasses, for protected(set)). The two features solve different problems and can be combined (see below) when you want both restrictions at once.

A useful mental model: symmetric visibility answers "who can touch this property at all?"; asymmetric visibility answers "who can look vs who can change?" separately; and readonly answers "how many times, ever, can this be set?" independent of who's asking. Understanding these as three separate, composable questions makes it much easier to pick the right combination for a given property instead of reaching for whichever one you remember first.

• Asymmetric visibility restricts direct property assignment ($obj->prop = ...) from outside the allowed scope, not all internal mutation that happens via public methods. • private(set) allows unlimited reassignment from inside the allowed scope — it is not the same as readonly's write-once guarantee. • Three separate questions: symmetric visibility (who can touch at all), asymmetric visibility (read vs write scope), readonly (how many times ever). • These three features compose — you can combine asymmetric visibility with readonly on the same property (see below).

Inheritance and Constructor Promotion

A subclass inherits a parent's asymmetrically-visible property and its restrictions as declared — it cannot loosen the write visibility in a way that violates the parent's contract (for instance, a subclass cannot expose write access to outside code for a property the parent declared private(set), since that would break the parent's own guarantee about who can mutate its state). A property declared public protected(set) in a parent is exactly the right tool when you want subclasses to be able to set a property that outside code should never directly write to — the subclass, being 'inside' the protected boundary, can assign to it freely.

Constructor property promotion (PHP 8.0+ syntax) combines directly with asymmetric visibility (PHP 8.4+): public function __construct(public private(set) string $email) {} promotes the constructor parameter straight into an asymmetrically-visible property in one declaration, with no separate property line needed. This is extremely common in practice — most asymmetric-visibility properties in real code are constructor-promoted this way, since the class's constructor is naturally the place that first sets internal state.

One subtlety worth knowing: because the class itself (not just the constructor) retains write access under private(set)/protected(set), a promoted asymmetric property is not automatically write-once — if you want the guarantee that only the constructor can ever set it (and never again, even from another method in the same class), combine it with readonly, covered next.

• A subclass cannot widen write access beyond what the parent declared for an inherited asymmetric property. • public protected(set) is the right choice when subclasses (but never outside code) need write access. • Constructor promotion + asymmetric visibility: public function __construct(public private(set) string $email) {} (PHP 8.4+). • private(set)/protected(set) alone does not mean write-once — the class can still reassign later unless combined with readonly.

Combining with readonly, and Static Asymmetric Visibility

Asymmetric visibility and readonly solve different problems and combine cleanly: public readonly string $id; already means "anyone can read, and it can be set exactly once, from anywhere" (readonly properties are implicitly write-restricted to a single assignment, typically in the constructor, and PHP already treats that single assignment as allowed from the declaring class's own scope). Because a plain readonly property's single allowed write already only happens from inside the declaring class in practice, explicitly adding private(set) to a readonly property is mostly redundant for the single-assignment case — but it becomes meaningful when you want to be explicit about intent, or in more complex hierarchies where you want to guarantee no subclass can ever attempt the readonly-permitted single write either (using private(set) instead of protected(set) narrows that single legal assignment down to the exact declaring class).

PHP 8.5+ extends asymmetric visibility to static properties, which PHP 8.4 did not support — under PHP 8.4, asymmetric visibility was instance-property-only. So public private(set) static int $count; (PHP 8.5+) becomes valid, letting a static property be publicly readable but only writable from inside its declaring class — useful for a public read-only counter or registry that only the class itself should be able to increment. Be careful labeling this one: it is a PHP 8.5+ feature layered on top of the PHP 8.4+ asymmetric-visibility feature, not available on 8.4 itself.

In practice, choose readonly when the value should never change after its one legitimate assignment (an ID, a creation timestamp, an injected dependency); choose asymmetric visibility alone when the value legitimately changes over the object's lifetime but only the class itself (or class+subclasses) should be the one changing it (an internal counter, a computed cache, a status field advanced by the class's own methods).

• readonly + asymmetric visibility can combine, though for the common single-assignment case readonly alone often already implies the practical restriction. • Static asymmetric visibility (PHP 8.5+, NOT 8.4) — e.g. public private(set) static int $count; — extends the feature to static properties. • Choose readonly for values set once and never changed again; choose plain asymmetric visibility for values that legitimately change, but only from inside the class. • Always double-check: instance-property asymmetric visibility is PHP 8.4+, but STATIC asymmetric visibility specifically requires PHP 8.5+.

Asymmetric Visibility Declaration Forms

Instance-property asymmetric visibility (public private(set), public protected(set), protected private(set)) is PHP 8.4+. Static asymmetric visibility (the last example) requires PHP 8.5+ — it will not work on PHP 8.4. Write visibility must be equal to or stricter than read visibility; e.g. private public(set) is always invalid.

class User
{
    // PHP 8.4+: anyone reads; only User itself writes
    public private(set) string $email;

    // PHP 8.4+: anyone reads; User and subclasses write
    public protected(set) int $loginCount = 0;

    // PHP 8.4+: subclasses read; only the declaring class writes
    protected private(set) string $internalId;

    // PHP 8.4+: constructor promotion + asymmetric visibility together
    public function __construct(
        public private(set) string $email,
    ) {}
}

class Counter
{
    // PHP 8.5+ ONLY — static asymmetric visibility did not exist in 8.4
    public private(set) static int $instances = 0;
}

Code Examples

Reading $u->email from outside the class works freely because the read visibility is public. Writing it from inside the class — in the constructor, or in changeEmail() — works because private(set) permits writes from the declaring class's own scope. The final attempted write from outside the class throws an Error, because private(set) blocks direct assignment from anywhere but User itself. Expected result: [email protected] [email protected] Cannot modify private(set) property User::$email from global scope public private(set) string $email;

protected(set) lets SavingsAccount (a subclass) write to $balance directly in applyInterest(), which private(set) would have forbidden. Outside code can still read $s->balance freely (public read), but direct outside assignment is blocked exactly like the private(set) case, since protected is still stricter than public. Expected result: 105 blocked: Cannot modify protected(set) property Account::$balance from global scope public protected(set) float $balance = 0.0;

<?php
class User
{
    public private(set) string $email;

    public function __construct(string $email)
    {
        $this->email = strtolower(trim($email));   // OK: write from inside the class
    }

    public function changeEmail(string $newEmail): void
    {
        $this->email = strtolower(trim($newEmail)); // OK: still inside the class
    }
}

$u = new User('[email protected]');
echo $u->email . "\n";            // [email protected] — reading from outside is fine

$u->changeEmail('[email protected]');
echo $u->email . "\n";            // [email protected]

try {
    $u->email = '[email protected]'; // FAILS: writing from outside the class
} catch (\Error $e) {
    echo $e->getMessage() . "\n";
}
<?php
class Account
{
    public protected(set) float $balance = 0.0;

    public function deposit(float $amount): void
    {
        $this->balance += $amount;   // OK: inside declaring class
    }
}

class SavingsAccount extends Account
{
    public function applyInterest(float $rate): void
    {
        $this->balance += $this->balance * $rate; // OK: subclass, protected(set) allows it
    }
}

$s = new SavingsAccount();
$s->deposit(100.0);
$s->applyInterest(0.05);
echo $s->balance . "\n";          // 105

try {
    $s->balance = 999999.0;        // FAILS: outside code, even though it's public to READ
} catch (\Error $e) {
    echo "blocked: " . $e->getMessage() . "\n";
}

Read vs Write Access Boundary

public private(set) draws two different boundaries: reads pass through from anywhere, writes are stopped at the class boundary.

class User {
      public private(set) string $email;
  }

           OUTSIDE CODE                    INSIDE THE CLASS
        (any other file/scope)          (User's own methods)
   ┌─────────────────────────┐      ┌─────────────────────────┐
   │  echo $user->email;      │ OK   │  $this->email = $x;      │ OK
   │  (READ allowed — public) │ ───▶ │  (WRITE allowed — the      │
   │                           │      │   declaring class itself) │
   └─────────────────────────┘      └─────────────────────────┘
   ┌─────────────────────────┐
   │  $user->email = 'x';      │ BLOCKED — Error thrown:
   │  (WRITE from outside)     │ "Cannot modify private(set)
   └─────────────────────────┘    property ... from global scope"

Important Rules

Asymmetric property visibility requires PHP 8.4 or newer; static asymmetric visibility additionally requires PHP 8.5+.

Write visibility must be equal to or stricter than read visibility — private public(set) is always a compile-time error.

Asymmetric visibility restricts direct property assignment, not all internal state change reachable via public methods.

private(set)/protected(set) alone does not mean write-once — combine with readonly when a true single-assignment guarantee is required.

A subclass can never widen the write access an ancestor declared for an inherited asymmetric property.

Replacing Getter-Only Encapsulation

A huge share of real-world 'private property + public getter, no setter' code exists purely to make a property look read-only from outside a class while the class freely mutates it internally — exactly what public private(set) and public protected(set) express directly. Domain models (an Order's $status advanced only by the Order's own state-machine methods, a User's $loginCount incremented only by the authentication service) are natural fits: outside code should always be able to check the current value, but only the object's own logic should be allowed to change it.

class User { private public(set) string $email; // INVALID: write side looser than read side } class User { public private(set) string $email; // valid: write side stricter than (or equal to) read side }

The write visibility can never be looser than the read visibility — code that couldn't even read a property being able to write it makes no sense, so PHP rejects private public(set) (and any similar looser-write-than-read combination) at compile time.

What does public private(set) string $email; allow? A. Any code can both read and write $email B. Any code can read $email; only the declaring class can write it C. Only the declaring class can read or write $email D. Only subclasses can read $email; nobody can write it

B. Any code can read $email; only the declaring class can write it — public private(set) gives public read access to everyone, while restricting direct write access to code inside the declaring class only.

Which of these is a valid asymmetric visibility declaration? A. private public(set) string $x; B. protected public(set) string $x; C. public private(set) string $x; D. private protected(set) string $x; with a wider public(set)

C. public private(set) string $x; — Write visibility must be equal to or stricter than read visibility. public private(set) is valid (write stricter than read); the others attempt to make write looser than read, which PHP rejects.

What minimum PHP version is required for STATIC asymmetric property visibility (e.g. public private(set) static int $count;)? A. PHP 8.3 B. PHP 8.4 C. PHP 8.5 D. It is not supported on any version yet

C. PHP 8.5 — Instance-property asymmetric visibility arrived in PHP 8.4, but extending it to static properties specifically requires PHP 8.5+ — it was not part of the PHP 8.4 release.

Design a Ticket class with a $status property (string) that: any code can read, only Ticket's own methods can write, and subclasses of Ticket should also be allowed to write it. Which asymmetric visibility combination fits, and write the property declaration.

public protected(set) string $status = 'open'; // public: any code can read $ticket->status // protected(set): Ticket and any class extending Ticket can assign to $status directly; outside code cannot.

You need public reads, and writes allowed for both the declaring class and its subclasses.