0

PHP 8.4 asymmetric visibility (public private(set))

Advanced5 min read·php-02-019
interview

Concept

Asymmetric visibility (PHP 8.4) lets you declare different access levels for reading and writing a property — most commonly public private(set), meaning the property is publicly readable but can only be written from within the class itself. Before PHP 8.4, achieving this required a private backing field with a public getter method, breaking the property-access ergonomics.

The syntax is public protected(set) or public private(set) — the first keyword is the read visibility, the keyword in parentheses is the write visibility. The write visibility must be equal to or more restrictive than the read visibility.

Primary use case — immutable-ish value objects: You want to expose state (e.g., a user's createdAt) publicly but prevent external code from mutating it. Unlike readonly, asymmetric visibility allows internal mutation after construction — useful for entities that update their own state (e.g., a readCount that increments on each access).

Compared to readonly: readonly allows a property to be written exactly once, then never again. public private(set) allows the class itself to write the property any number of times — the restriction is about who can write, not how many times.

Code Example

php
<?php
declare(strict_types=1);

class BlogPost
{
    // Publicly readable, only writable from inside BlogPost
    public private(set) int $viewCount = 0;
    public private(set) \DateTimeImmutable $publishedAt;

    // Protected(set) — writable from class and subclasses
    public protected(set) string $status = 'draft';

    public function __construct(
        public readonly string $title, // readonly = written once, forever immutable
        public readonly string $slug,
    ) {
        $this->publishedAt = new \DateTimeImmutable();
    }

    public function recordView(): void
    {
        $this->viewCount++; // allowed — writing from inside the class
    }

    public function publish(): void
    {
        $this->status = 'published'; // allowed — inside class
    }
}

$post = new BlogPost('PHP 8.4 Is Here', 'php-84-is-here');

echo $post->viewCount;  // 0  — public read ✓
$post->recordView();
echo $post->viewCount;  // 1

// $post->viewCount = 99;  // Fatal: Cannot modify private(set) property from outside class
// $post->title = 'New';   // Fatal: Cannot modify readonly property

class FeaturedPost extends BlogPost
{
    public function feature(): void
    {
        $this->status = 'featured'; // allowed — protected(set) is visible to subclasses
    }
}

Interview Q&A

Q: What is asymmetric visibility in PHP 8.4 and how does it differ from readonly?

Asymmetric visibility (public private(set)) separates read and write access levels — the property is publicly readable but can only be written by the declaring class. readonly (PHP 8.1) is write-once: any code in the class can write it exactly once during object lifetime, after which it is permanently frozen. The key difference is frequency and who: asymmetric visibility restricts who writes (only the class itself), while readonly restricts how many times (once, by anyone with write access). Use readonly for value objects whose state never changes; use public private(set) for entities that manage their own mutable state but should not expose mutation to the outside world.


Q: What visibility combinations are valid with asymmetric visibility?

The write visibility must be equal to or more restrictive than the read visibility. Valid combinations: public protected(set) (publicly readable, class and subclasses can write), public private(set) (publicly readable, only the class can write), protected private(set) (protected read, only the class can write). Invalid: private public(set) would make something privately readable but publicly writable, which is nonsensical. You cannot use asymmetric visibility on readonly properties — they are already write-once, making the combination redundant.


Q: How would you use asymmetric visibility to model a domain entity's lifecycle?

Consider an Order entity: public private(set) string $status = 'pending' and public private(set) \DateTimeImmutable $updatedAt. The Order class exposes confirm(), ship(), cancel() methods that perform the state transitions and update $updatedAt internally. External code reads $order->status freely but cannot bypass the business logic by writing $order->status = 'shipped' directly. This is safer than relying on convention (_private naming) and cleaner than the getter/setter pattern — the entity's state machine is enforced by the language.