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

public

anywhere (the default for methods and constants when omitted)

protected

the declaring class, its subclasses and its parent classes

private

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 $at can still be ->modify()-ed; use DateTimeImmutable.

  • 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.

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

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

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)

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

public private(set)

read anywhere / write in the class

public protected(set)

read anywhere / write in the class and subclasses

protected private(set)

read in the class hierarchy / write in the class

private(set) alone

read visibility defaults to public

readonly

implies protected(set) unless another set visibility is given (8.4)

<?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.

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.

Choosing between the tools

Need Use

Data that never changes after construction

readonly properties or a readonly class

State that changes, but only through the object’s methods

public private(set) (or protected(set) for subclasses)

Validation or transformation on every write

a set hook (Property Hooks)

A computed value that is never stored

a virtual property with a get hook, or a method

Implementation details

private

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.

  • var as a property keyword (PHP 4) is a synonym for public.