Visibility, Readonly and Asymmetric Visibility
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against PHP 8.5, checked on 2026-10-05; readonly property defaults, upcoming in PHP 8.6, were run on the 8.6 release candidate. PHP gives three tools for controlling who can read and change an object’s state: visibility (who can access a member), readonly (a property can be written once), and asymmetric visibility (reading and writing have different visibilities). Property hooks, the fourth tool, have their own page: Property Hooks.
public, protected and private
Every property, method and class constant has a visibility:
| Modifier | Accessible from |
|---|---|
|
anywhere (the default for methods and constants when omitted) |
|
the declaring class, its subclasses and its parent classes |
|
the declaring class only |
Visibility is per class, not per object: a method can access the private members of another instance of the same
class, which is what makes equals() and with…() methods possible.
<?php
declare(strict_types=1);
class Account
{
protected const MAX_LOANS = 5; // visible to subclasses
private int $fines = 0; // visible to Account only
public function __construct(public string $memberId) {}
public function sameMember(Account $other): bool
{
return $this->memberId === $other->memberId && $this->fines === $other->fines; // other instance's private
}
protected function charge(int $cents): void
{
$this->fines += $cents;
}
public function fines(): int { return $this->fines; }
}
final class StudentAccount extends Account
{
public function lateReturn(): void
{
$this->charge(25); // protected method: OK
echo 'limit ', self::MAX_LOANS, "\n"; // protected constant: OK
// $this->fines++; // Error: Cannot access private property
}
}
$s = new StudentAccount('M-17');
$s->lateReturn();
echo $s->fines(), "\n"; // 25
var_dump($s->sameMember(new StudentAccount('M-17'))); // bool(false): fines differ
Prefer private and widen deliberately: everything protected is part of the contract with subclasses. Methods
declared in interfaces are always public.
Source: Visibility
readonly properties (PHP 8.1)
A readonly property can be initialised once, from inside the class scope, and never modified afterwards. It must be
typed and cannot have a default value (until PHP 8.6). Attempting a second write, or a write from outside the class,
throws an Error.
<?php
declare(strict_types=1);
final class Book
{
public function __construct(
public readonly string $isbn,
public readonly string $title,
public readonly array $tags = [],
) {}
}
$book = new Book('978-0-00-000001-1', 'Kindred', ['sf']);
echo $book->title, "\n";
foreach (['title', 'tags'] as $what) {
try {
match ($what) {
'title' => $book->title = 'Dawn', // a second write
'tags' => $book->tags[] = 'classic', // modifying an array in place counts too
};
} catch (Error $e) {
echo $e->getMessage(), "\n";
}
}
Kindred
Cannot modify readonly property Book::$title
Cannot indirectly modify readonly property Book::$tags
Things to know about readonly:
-
Shallow: a readonly property holding an object prevents replacing the object, not changing it.
readonly DateTime $atcan still be->modify()-ed; useDateTimeImmutable. -
Initialisation scope: only code in the declaring class can initialise it (since 8.4, readonly properties are implicitly
protected(set), see below, so a subclass may initialise them too). -
Uninitialised until set: a readonly property not set in the constructor stays uninitialised and can be set once later by the class.
-
No
unset()after initialisation, and no references to it.
Source: Readonly properties
readonly classes (PHP 8.2)
Declaring the class readonly makes every declared property readonly and forbids dynamic properties. All properties
must be typed, and a readonly class can only extend or be extended by another readonly class. Anonymous classes can be
readonly since 8.3.
<?php
declare(strict_types=1);
final readonly class Money
{
public function __construct(public int $amount, public string $currency) {}
public function add(Money $other): self
{
if ($other->currency !== $this->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new self($this->amount + $other->amount, $this->currency);
}
}
$fine = (new Money(250, 'EUR'))->add(new Money(75, 'EUR'));
echo $fine->amount, ' ', $fine->currency, "\n"; // 325 EUR
Source: Readonly classes
Cloning objects with readonly properties
Before 8.3, a __clone() method could not deep-copy a readonly property, because that is a second write. Since PHP
8.3, readonly properties can be re-initialised once inside __clone(). Since 8.5, clone() is also a function that
takes an array of property values to set on the copy — the clean way to write "withers" on immutable objects, covered
in Cloning, Late Static Binding
and Object Lifecycle:
<?php
declare(strict_types=1);
final readonly class Loan
{
public function __construct(
public string $isbn,
public DateTimeImmutable $due,
public int $renewals = 0,
) {}
public function renew(int $days): self
{
// 8.5: clone with new values for some properties
return clone($this, ['due' => $this->due->modify("+{$days} days"), 'renewals' => $this->renewals + 1]);
}
}
final class Snapshot
{
public function __construct(public readonly ArrayObject $data) {}
public function __clone()
{
$this->data = clone $this->data; // 8.3: re-initialise a readonly property during cloning
}
}
$loan = new Loan('978-0-00-000001-1', new DateTimeImmutable('2026-10-26'));
$renewed = $loan->renew(14);
echo $loan->due->format('Y-m-d'), ' -> ', $renewed->due->format('Y-m-d'), " ({$renewed->renewals})\n";
$a = new Snapshot(new ArrayObject(['x']));
$b = clone $a;
$b->data[] = 'y';
echo count($a->data), ' ', count($b->data), "\n"; // 1 2
Source: Object Cloning and PHP 8.3 new features
readonly property defaults (upcoming in PHP 8.6)
PHP 8.6 lifts the "no default value" restriction: a readonly property may declare a default, and is then initialised with it. In 8.5 the same code is a compile-time error ("Readonly property X::$limit cannot have default value").
<?php
declare(strict_types=1);
// requires PHP 8.6
final class LoanPolicy
{
public readonly int $maxLoans = 5;
}
var_dump((new LoanPolicy())->maxLoans); // int(5)
Source: RFC: Readonly property defaults
Asymmetric visibility (PHP 8.4)
Asymmetric visibility gives a property a different visibility for writing than for reading. The set visibility
is written after the read visibility, in parentheses: public private(set) int $copies can be read from anywhere but
written only inside the class. The set visibility must be equal to or narrower than the read one, and the property
must be typed.
| Declaration | Read / write |
|---|---|
|
read anywhere / write in the class |
|
read anywhere / write in the class and subclasses |
|
read in the class hierarchy / write in the class |
|
read visibility defaults to |
|
implies |
<?php
declare(strict_types=1);
final class Copy
{
public function __construct(
public readonly string $isbn,
public private(set) string $status = 'available', // readable everywhere, writable here only
) {}
public function lend(): void
{
if ($this->status !== 'available') {
throw new LogicException("Copy of {$this->isbn} is {$this->status}");
}
$this->status = 'on loan';
}
}
$copy = new Copy('978-0-00-000001-1');
$copy->lend();
echo $copy->status, "\n"; // on loan -- no getter needed
try {
$copy->status = 'lost';
} catch (Error $e) {
echo $e->getMessage(), "\n"; // Cannot modify private(set) property Copy::$status from global scope
}
Compared with readonly, asymmetric visibility allows repeated writes from inside the class, which suits entities
with state transitions; compared with a private property plus a getter, it removes boilerplate while keeping the same
guarantee. Asymmetric visibility also controls references and array modification: $copy->tags[] = 'x' from outside
counts as a write.
Source: Asymmetric Property Visibility
On static properties (PHP 8.5)
Since PHP 8.5 static properties can use asymmetric visibility too:
<?php
declare(strict_types=1);
final class LoanCounter
{
public static private(set) int $total = 0;
public static function record(): void
{
self::$total++;
}
}
LoanCounter::record();
LoanCounter::record();
echo LoanCounter::$total, "\n"; // 2
try {
LoanCounter::$total = 0;
} catch (Error $e) {
echo $e->getMessage(), "\n"; // Cannot modify private(set) property LoanCounter::$total from global scope
}
PHP 8.5 also allows final on promoted constructor properties (public function __construct(final public string
$id)), preventing subclasses from redeclaring them.
Building immutable value objects
A value object represents a value (an ISBN, a sum of money, a date range): it is defined by its data, has no identity and should be immutable. Modern PHP makes them short:
<?php
declare(strict_types=1);
final readonly class Isbn
{
public string $value;
public function __construct(string $value)
{
$digits = str_replace(['-', ' '], '', $value);
if (!preg_match('/^97[89]\d{10}$/', $digits)) {
throw new InvalidArgumentException("Not an ISBN-13: {$value}");
}
$this->value = $digits; // validated once, then immutable
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
$a = new Isbn('978-0-00-000001-1');
$b = new Isbn('9780000000011');
var_dump($a->equals($b)); // bool(true)
echo $a, "\n"; // 9780000000011
try {
new Isbn('12345');
} catch (InvalidArgumentException $e) {
echo $e->getMessage(), "\n";
}
The recipe: a final readonly class, validation in the constructor (an invalid value cannot exist), an equals()
method instead of relying on ==, DateTimeImmutable and other immutable types for nested values, and clone()
with properties (8.5) or a named constructor for derived values.
Source: Readonly properties
Choosing between the tools
| Need | Use |
|---|---|
Data that never changes after construction |
|
State that changes, but only through the object’s methods |
|
Validation or transformation on every write |
a |
A computed value that is never stored |
a virtual property with a |
Implementation details |
|
Where older books and tutorials are outdated
-
Getter/setter pairs for every property were the standard advice in books for PHP 5 and 7;
readonly(8.1), asymmetric visibility (8.4) and property hooks (8.4) make most of them unnecessary. -
Immutability by convention (private properties, no setters, "please don’t modify") can now be enforced.
-
Deep cloning of readonly properties was impossible before 8.3, which led some code to avoid
readonly. -
varas a property keyword (PHP 4) is a synonym forpublic.
References
-
PHP Manual — Visibility (including asymmetric property visibility)
-
PHP Manual — Properties (readonly properties)
-
PHP Manual — The Basics (readonly classes)
-
PHP RFC — Readonly property defaults (PHP 8.6)