Asymmetric Visibility (PHP 8.4)
What Is Asymmetric Visibility? In simple terms: Often, everyone should be able to read a property, but only the class itself should be able to change it. An order's status, for example, should be visi
What Is Asymmetric Visibility?
In simple terms: Often, everyone should be able to read a property, but only the class itself should be able to change it. An order's status, for example, should be visible everywhere, but it should change only through methods like pay() and ship() that enforce the rules. Asymmetric visibility lets you declare exactly that: public to read, private to write.
Technically: Since PHP 8.4, a property can have separate visibility for reading and writing. The write ("set") visibility is written in parentheses: public private(set) string $status. The first modifier controls reading; private(set) or protected(set) controls writing. The set visibility may not be wider than the read visibility.
The Problem It Solves
| Before PHP 8.4 | Drawback |
|---|---|
| Private property + public getter method | Boilerplate: a getter for every readable property |
public readonly property | Can be set only once, so the class itself cannot update it later |
| Public property | Anyone can change it to anything |
With asymmetric visibility, the property is readable like a public property, changeable as often as the class needs, and protected from outside modification.
Syntax
public private(set) string $status; // read: public, write: private
public protected(set) int $count; // read: public, write: this class and subclasses
protected private(set) array $cache; // read: protected, write: private
private(set) string $name; // shorthand: read is public
A First Example: An Order Status
<?php
final class Order
{
public private(set) string $status = 'pending';
public private(set) array $history = [];
public function __construct(public readonly int $id) {}
public function pay(): void
{
$this->transition('pending', 'paid');
}
public function ship(): void
{
$this->transition('paid', 'shipped');
}
private function transition(string $from, string $to): void
{
if ($this->status !== $from) {
throw new LogicException("Order {$this->id} cannot go from {$this->status} to $to.");
}
$this->history[] = "{$this->status} → $to";
$this->status = $to;
}
}
$order = new Order(501);
echo "Status: {$order->status}\n";
$order->pay();
$order->ship();
echo "Status: {$order->status}\n";
echo implode(', ', $order->history) . "\n";
$attempts = [
'assign status' => fn() => $order->status = 'delivered',
'append history' => fn() => $order->history[] = 'hacked',
];
foreach ($attempts as $label => $attempt) {
try {
$attempt();
} catch (Error $e) {
echo "$label: " . $e->getMessage() . "\n";
}
}
Output
Status: pending
Status: shipped
pending → paid, paid → shipped
assign status: Cannot modify private(set) property Order::$status from global scope
append history: Cannot modify private(set) property Order::$history from global scope
How It Works
public private(set)makesstatusandhistoryreadable everywhere —$order->statusworks in templates and API responses without getter methods.- Only code inside
Ordermay write them. The class changes the status repeatedly (pending → paid → shipped), which a readonly property could not do. - The only way to change the status is through methods that enforce the allowed transitions and record the history.
- Outside writes are rejected — including indirect modifications such as appending to an array property.
protected(set): Writable by Subclasses
<?php
abstract class Counter
{
public protected(set) int $count = 0;
abstract public function record(): void;
}
final class DownloadCounter extends Counter
{
public function record(): void
{
$this->count++; // allowed: subclass
}
}
$downloads = new DownloadCounter();
$downloads->record();
$downloads->record();
$downloads->record();
echo "Downloads: {$downloads->count}\n";
try {
$downloads->count = 1000;
} catch (Error $e) {
echo $e->getMessage() . "\n";
}
Output
Downloads: 3
Cannot modify protected(set) property Counter::$count from global scope
protected(set) lets child classes update the value while still preventing outside code from doing so. The error message names Counter, the class that declares the property.
Constructor Promotion
Asymmetric visibility works with promoted constructor parameters:
<?php
final class Customer
{
public function __construct(
public private(set) string $name,
public private(set) string $email,
) {}
public function changeEmail(string $email): void
{
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new InvalidArgumentException("Invalid email: $email");
}
$this->email = strtolower($email);
}
}
$customer = new Customer('Asha', 'asha@example.com');
$customer->changeEmail('Asha.K@GoCourse.in');
echo "{$customer->name} <{$customer->email}>\n";
Output
Asha <asha.k@gocourse.in>
Callers read $customer->email directly, but changing it must go through changeEmail(), which validates the new address.
Combining With Property Hooks
Asymmetric visibility controls who may write; property hooks control what happens on write. Together they cover many cases elegantly:
public private(set) string $sku {
set => strtoupper(trim($value));
}
The SKU is publicly readable, writable only inside the class, and always normalized when written (see Property Hooks (PHP 8.4)).
Asymmetric Visibility vs Readonly
public readonly | public private(set) | |
|---|---|---|
| Readable from outside | Yes | Yes |
| Writable from outside | No | No |
| Can the class change it later? | No — initialized once | Yes, as often as needed |
| Can be combined with property hooks | No | Yes |
| Typical use | Immutable values (IDs, value objects) | State that changes through controlled methods (status, counters) |
Since PHP 8.4, readonly properties are implicitly protected(set), which lets child classes initialize them; they still can only be initialized once.
Rules
| Rule | Detail |
|---|---|
| The property must have a type | Untyped properties cannot use asymmetric visibility |
| Set visibility cannot be wider than read visibility | private public(set) is invalid |
| Shorthand | private(set) string $x means public private(set) |
| Static properties | Supported for static properties from PHP 8.5 |
private(set) implies final | Subclasses cannot redeclare a private(set) property, because they could not write it anyway |
Common Mistakes
Using readonly for Values the Class Must Update
Effect: Cannot modify readonly property when the class tries to change the value later.
Fix: Use public private(set).
Expecting Arrays to Be Modifiable From Outside
Effect: Appending or changing elements from outside throws an error.
Fix: Provide methods such as addItem().
Exposing Writes Through Methods Without Checks
Effect: A setStatus(string $s) method that accepts anything defeats the purpose.
Fix: Write intention-revealing methods (pay(), ship()) that enforce the rules.
Forgetting the Type
Effect: Compile error.
Fix: Declare a type for every property with asymmetric visibility.
Best Practices
- Use
public private(set)for state that others may read but only the object may change. - Use
protected(set)when subclasses must update the value. - Change such properties only through methods that express business actions and enforce rules.
- Prefer
public readonlyfor values that never change after construction. - Combine asymmetric visibility with
sethooks for normalization and validation.
Official reference: https\://www.php.net/manual/en/language.oop5.visibility.php#language.oop5.visibility-members-aviz