PHP readonly e Property Hooks

Uma propriedade public aceita qualquer coisa, a qualquer momento, de qualquer lugar: o número de um pedido pode ser trocado por engano, um preço pode virar negativo. O PHP moderno tem duas ferramentas para controlar uma propriedade sem precisar escrever métodos getAlgo()/setAlgo(): o readonly ("somente leitura": o valor é gravado uma vez e nunca mais muda) e os property hooks ("ganchos de propriedade": um código seu roda toda vez que alguém lê ou grava a propriedade).

Você vai aprender
  • Propriedades readonly PHP 8.1+: as regras e os erros reais.
  • Classes readonly PHP 8.2+: todas as propriedades de uma vez.
  • Como "mudar" um objeto readonly: criar uma cópia com clone e o clone com novos valores PHP 8.5+, e por que de fora da classe ele dá erro.
  • Property hooks get e set PHP 8.4+: validar, limpar e calcular valores.
  • Qual ferramenta usar em cada caso.

Pré-requisitos: construtores (com promoção de propriedades), visibilidade e exceções. O clone e o __clone aparecem em Métodos Mágicos, mas vamos relembrar aqui. Os exemplos rodam na máquina fictícia da Ana, na pasta ~/poo.


A ideia antes do código: a nota fiscal e o porteiro

Uma nota fiscal, depois de emitida, não se rasura. Se o valor estava errado, emite-se outra nota. Isso é o readonly: o objeto nasce com os valores certos e eles não mudam mais; para ter valores diferentes, cria-se outro objeto.

Já um porteiro fica na entrada e confere quem entra ("tem crachá?") e às vezes ajusta ("assine aqui"). Isso é o hook set: todo valor que entra na propriedade passa por ele. E o hook get é como uma placa que é calculada na hora em que alguém olha.


1. Propriedades readonly PHP 8.1+

Escreva readonly antes do tipo. O jeito mais comum é junto com a promoção no construtor:

<?php
class Pedido
{
    public function __construct(
        public readonly int $numero,
        public readonly string $cliente,
    ) {}
}

$p = new Pedido(1001, "Ana");
echo "Pedido $p->numero de $p->cliente\n";
$p->numero = 2002;
echo "fim\n";
public readonly int $numero
public: todo mundo pode ler. readonly: só pode ser gravada uma vez. int: o tipo, que é obrigatório.
new Pedido(1001, "Ana")
O construtor grava os valores: essa é a "uma vez".
echo "… $p->numero …"
Ler funciona normalmente, de qualquer lugar.
$p->numero = 2002;
Tentar gravar de novo: erro.
$ php pedido.php
Pedido 1001 de Ana

Fatal error: Uncaught Error: Cannot modify readonly property Pedido::$numero in /home/ana/poo/pedido.php:12
Stack trace:
#0 {main}
  thrown in /home/ana/poo/pedido.php on line 12

"Não é possível modificar a propriedade somente leitura Pedido::$numero". O fim não aparece: é um Error que para o programa (a não ser que você o capture com try/catch).

Nem a própria classe pode mudar depois

O readonly não é só "de fora". Depois de gravada, nem um método da classe muda a propriedade:

<?php
class Pedido
{
    public function __construct(
        public readonly int $numero,
    ) {}

    public function renumerar(int $novo): void
    {
        $this->numero = $novo;
    }
}

$p = new Pedido(1001);
$p->renumerar(2002);
$ php renumerar.php

Fatal error: Uncaught Error: Cannot modify readonly property Pedido::$numero in /home/ana/poo/renumerar.php:10
Stack trace:
#0 /home/ana/poo/renumerar.php(15): Pedido->renumerar(2002)
#1 {main}
  thrown in /home/ana/poo/renumerar.php on line 10

As regras do readonly

RegraSe desobedecer
Precisa ter tipo (int, string, array…)Fatal error: Readonly property Pedido::$numero must have type
Não pode ter valor padrão (= 1)Fatal error: Readonly property Pedido::$numero cannot have default value
É gravada uma vez sóCannot modify readonly property Pedido::$numero
Só é gravada de dentro da classe (normalmente no construtor)Cannot modify protected(set) readonly property Pedido::$numero from global scope
Precisa ser gravada antes de ser lidaTyped property Pedido::$numero must not be accessed before initialization

A penúltima linha merece atenção. Desde o PHP 8.4, readonly significa também protected(set): "só a própria classe (e as classes filhas) podem gravar". Veja o que acontece se a Ana deixa o construtor de lado e tenta preencher de fora:

<?php
class Pedido
{
    public readonly int $numero;
}
$p = new Pedido();
$p->numero = 5;
$ php de-fora.php

Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Pedido::$numero from global scope in /home/ana/poo/de-fora.php:7
Stack trace:
#0 {main}
  thrown in /home/ana/poo/de-fora.php on line 7

"Não é possível modificar a propriedade readonly protected(set) a partir do escopo global" (o "escopo global" é o código fora de qualquer classe). Guarde essa mensagem: ela vai voltar quando falarmos de clone.

Missão: o número do pedido nunca deveria mudar, mas o código troca para 9999 sem reclamar. Rode e veja. Depois coloque readonly na propriedade $numero (deixe o $status como está, porque ele muda de verdade) e rode de novo: o PHP deve recusar a troca com Cannot modify readonly property.

<?php
class Pedido
{
    public function __construct(
        public int $numero,
        public string $status,
    ) {}
}

$p = new Pedido(1001, "Pendente");
$p->status = "Pago";
echo "Pedido $p->numero: $p->status\n";
$p->numero = 9999;
echo "Pedido $p->numero: $p->status\n";
<?php
class Pedido
{
    public function __construct(
        public readonly int $numero,
        public string $status,
    ) {}
}

$p = new Pedido(1001, "Pendente");
$p->status = "Pago";
echo "Pedido $p->numero: $p->status\n";
$p->numero = 9999;
echo "Pedido $p->numero: $p->status\n";

Cuidado: readonly não "congela" o que está dentro

Uma propriedade readonly que guarda um objeto não deixa você trocar o objeto, mas o objeto em si continua mudando. Já um array readonly fica realmente travado:

<?php
class Cliente
{
    public function __construct(public string $cidade) {}
}

class Pedido
{
    public function __construct(
        public readonly Cliente $cliente,
        public readonly array $itens,
    ) {}
}

$p = new Pedido(new Cliente("Recife"), ["caderno"]);
$p->cliente->cidade = "Natal";
echo $p->cliente->cidade, "\n";
$p->itens[] = "caneta";
$ php dentro.php
Natal               « o Cliente de dentro mudou: ele não é readonly

Fatal error: Uncaught Error: Cannot indirectly modify readonly property Pedido::$itens in /home/ana/poo/dentro.php:18
Stack trace:
#0 {main}
  thrown in /home/ana/poo/dentro.php on line 18

O readonly protege a propriedade, não o objeto guardado nela. Se você quer que nada mude, o Cliente também precisa ter propriedades readonly.


2. Classes readonly PHP 8.2+

Quando todas as propriedades devem ser readonly, escreva readonly uma vez, antes de class. É o caso típico de objetos que são só "um valor": um ponto, um preço, uma data, um endereço.

<?php
readonly class Ponto
{
    public function __construct(
        public int $x,
        public int $y,
    ) {}
}
$p = new Ponto(3, 4);
echo "($p->x, $p->y)\n";
$p->cor = "azul";
$ php ponto.php
(3, 4)

Fatal error: Uncaught Error: Cannot create dynamic property Ponto::$cor in /home/ana/poo/ponto.php:11
Stack trace:
#0 {main}
  thrown in /home/ana/poo/ponto.php on line 11
readonly class Ponto
Todas as propriedades declaradas viram readonly (e valem as mesmas regras: tipo obrigatório, sem valor padrão).
$p->cor = "azul"
Numa classe comum, criar uma propriedade que não existe dá só um aviso Deprecated. Numa classe readonly é erro: "não é possível criar a propriedade dinâmica".

Sem o readonly na classe, você escreveria public readonly int $x, public readonly int $y. Use a classe readonly quando o objeto inteiro é imutável; use a propriedade readonly quando só algumas partes são fixas (como o Pedido com numero fixo e status que muda).


3. "Mudar" um objeto readonly: criar uma cópia

A Ana tem o preço do caderno, R$ 12,50, num objeto readonly. Vai ter promoção. Ela não muda o preço: ela cria outro objeto Preco com o valor novo (como a nota fiscal nova). O lugar certo para isso é um método da própria classe, que por costume se chama comAlgo() (em inglês, withAlgo()).

O clone com novos valores PHP 8.5+

No PHP 8.5, o clone pode ser escrito como uma função que recebe o objeto e um array com os valores que mudam: clone($objeto, ["propriedade" => novoValor]). Ele copia o objeto e já grava os valores novos na cópia. É o que se chama de "clone with".

<?php
readonly class Preco
{
    public function __construct(
        public float $valor,
        public string $moeda,
    ) {}

    public function comValor(float $valor): static
    {
        return clone($this, ["valor" => $valor]);
    }
}
$a = new Preco(10.0, "BRL");
$b = $a->comValor(12.5);
echo "$a->valor $a->moeda\n";
echo "$b->valor $b->moeda\n";
$ php preco.php
10 BRL          « o original não mudou
12.5 BRL        « a cópia tem o valor novo e a mesma moeda
public function comValor(float $valor): static
Um método que devolve um objeto do mesmo tipo (static = "a mesma classe deste objeto").
clone($this, …)
Copia o objeto atual…
["valor" => $valor]
…e na cópia grava valor com o número novo. As outras propriedades (a moeda) vêm iguais.

De fora da classe, dá erro (e isso é bom)

Por que não fazer o clone direto, no código principal, sem o método comValor?

<?php
readonly class Preco
{
    public function __construct(
        public float $valor,
        public string $moeda,
    ) {}
}
$a = new Preco(10.0, "BRL");
$b = clone($a, ["valor" => 12.5]);
var_dump($b);
$ php clone-fora.php

Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Preco::$valor from global scope in /home/ana/poo/clone-fora.php:10
Stack trace:
#0 /home/ana/poo/clone-fora.php(10): clone(Object(Preco), Array)
#1 {main}
  thrown in /home/ana/poo/clone-fora.php on line 10

É a mesma mensagem da seção 1. O clone com valores grava as propriedades obedecendo às mesmas regras de uma gravação comum. Como readonly é protected(set), só código de dentro da classe pode gravar. Por isso o caminho certo é o método comValor(): a classe decide o que pode ser trocado (e pode validar o valor novo antes).

Se você quer mesmo permitir de fora

Dá para declarar a propriedade como public public(set) float $valor ("leitura e gravação públicas, mas readonly"). Aí clone($a, ["valor" => 12.5]) funciona fora da classe, e o objeto continua sem poder ser alterado depois de criado. Na maioria dos casos, o método comValor() é mais claro.

Antes do PHP 8.5

Antes do clone com valores, o jeito mais simples era criar um objeto novo pelo construtor: return new static($valor, $this->moeda);. O que não funciona é clonar e depois gravar:

<?php
final class Preco
{
    public function __construct(
        public readonly float $valor,
    ) {}

    public function comValor(float $valor): static
    {
        $novo = clone $this;
        $novo->valor = $valor;
        return $novo;
    }
}
$b = (new Preco(10.0))->comValor(12.5);
echo $b->valor, "\n";
$ php clone-antigo.php

Fatal error: Uncaught Error: Cannot modify readonly property Preco::$valor in /home/ana/poo/clone-antigo.php:11
Stack trace:
#0 /home/ana/poo/clone-antigo.php(15): Preco->comValor(12.5)
#1 {main}
  thrown in /home/ana/poo/clone-antigo.php on line 11

A cópia já nasce com o valor gravado (copiado do original), então gravar de novo é "modificar". A única exceção é dentro do método __clone: desde o PHP 8.3, ali as propriedades readonly da cópia ainda podem ser regravadas (útil para clonar um objeto de dentro, a "cópia profunda").

Missão: complete o método comValor para devolver uma cópia do preço com o valor novo, usando clone($this, [...]). O preço original deve continuar 12.5.

<?php
readonly class Preco
{
    public function __construct(
        public float $valor,
        public string $moeda,
    ) {}

    public function comValor(float $valor): static
    {
        // devolva uma cópia de $this com o novo valor
    }
}

$caderno = new Preco(12.5, "BRL");
$promocao = $caderno->comValor(9.9);
echo "Antes: $caderno->valor $caderno->moeda\n";
echo "Promoção: $promocao->valor $promocao->moeda\n";
<?php
readonly class Preco
{
    public function __construct(
        public float $valor,
        public string $moeda,
    ) {}

    public function comValor(float $valor): static
    {
        return clone($this, ["valor" => $valor]);
    }
}

$caderno = new Preco(12.5, "BRL");
$promocao = $caderno->comValor(9.9);
echo "Antes: $caderno->valor $caderno->moeda\n";
echo "Promoção: $promocao->valor $promocao->moeda\n";

Se você rodar o código sem completar, o PHP reclama que o método não devolveu nada: Preco::comValor(): Return value must be of type Preco, none returned.


4. Property hooks PHP 8.4+

O readonly resolve "nunca muda". Mas e o preço da Ana, que muda, mas nunca pode ser negativo? Ou o nome do produto, que deveria chegar sem espaços sobrando? Antes do PHP 8.4, a saída era deixar a propriedade private e criar métodos getPreco() e setPreco(). Com os hooks, a regra fica na própria propriedade, e quem usa a classe continua escrevendo $p->preco = 15.

<?php
class Produto
{
    public string $nome {
        set(string $valor) {
            $valor = trim($valor);
            if ($valor === "") {
                throw new ValueError("O nome não pode ficar vazio");
            }
            $this->nome = $valor;
        }
    }

    public float $preco {
        set(float $valor) {
            if ($valor < 0) {
                throw new ValueError("Preço negativo: $valor");
            }
            $this->preco = $valor;
        }
    }

    public string $etiqueta {
        get => $this->nome . " - R$ " . number_format($this->preco, 2, ",", ".");
    }
}

$p = new Produto();
$p->nome = "  Caderno  ";
$p->preco = 12.5;
echo "[$p->nome]\n";
echo $p->etiqueta, "\n";
$p->preco = 15;
echo $p->etiqueta, "\n";
$p->preco = -3;
$ php hooks.php
[Caderno]                « o set tirou os espaços
Caderno - R$ 12,50       « o get montou a etiqueta na hora
Caderno - R$ 15,00       « e de novo, com o preço novo

Fatal error: Uncaught ValueError: Preço negativo: -3 in /home/ana/poo/hooks.php:17
Stack trace:
#0 /home/ana/poo/hooks.php(35): Produto->$preco::set(-3.0)
#1 {main}
  thrown in /home/ana/poo/hooks.php on line 17
public string $nome { … }
Em vez de ; no fim, a propriedade ganha um bloco { } com os hooks.
set(string $valor) { … }
O hook set roda toda vez que alguém grava $p->nome = …. $valor é o que está chegando.
$this->nome = $valor;
Dentro do próprio hook, gravar $this->nome guarda o valor de verdade (não chama o hook de novo). Se você não gravar, nada é guardado.
throw new ValueError(…)
Recusa o valor. ValueError é a exceção do PHP para "o tipo está certo, mas o valor não serve".
get => …;
O hook get roda toda vez que alguém lê a propriedade. A forma curta => devolve o resultado da expressão.
$etiqueta
Só tem get e não usa $this->etiqueta: é uma propriedade virtual, que não guarda nada; é calculada a cada leitura.

Na linha #0 do erro, repare em Produto->$preco::set(-3.0): o PHP mostra que o erro aconteceu dentro do hook set da propriedade $preco.

A forma curta do set e o $value

Quando o set só transforma o valor, há uma forma curta: set => expressão;. O valor que chega se chama automaticamente $value (em inglês, como o PHP define), e o resultado da expressão é o que fica guardado:

<?php
class Usuario
{
    public string $email {
        set => strtolower(trim($value));
    }

    public function __construct(string $email)
    {
        $this->email = $email;
    }
}
$u = new Usuario("  Ana@Exemplo.COM ");
var_dump($u->email);
$u->email = "BIA@exemplo.com";
var_dump($u->email);
$ php email.php
string(15) "ana@exemplo.com"      « o construtor também passa pelo hook
string(15) "bia@exemplo.com"

Hooks também funcionam na promoção do construtor: public string $nome { set => trim($value); }, dentro dos parênteses do __construct.

get e set juntos: uma propriedade calculada

<?php
class Temperatura
{
    public float $celsius = 0.0;

    public float $fahrenheit {
        get => $this->celsius * 9 / 5 + 32;
        set(float $valor) {
            $this->celsius = ($valor - 32) * 5 / 9;
        }
    }
}
$t = new Temperatura();
$t->celsius = 25;
echo $t->fahrenheit, "\n";
$t->fahrenheit = 212;
echo $t->celsius, "\n";
var_dump($t);
$ php temperatura.php
77
100                     « gravar em fahrenheit mudou o celsius
object(Temperatura)#1 (1) {
  ["celsius"]=>
  float(100)            « só o celsius existe de verdade
}

O $fahrenheit é virtual: lê e grava, mas o único valor guardado é o $celsius. O var_dump confirma.

Visibilidade assimétrica: só "ler de fora" PHP 8.4+

Às vezes você não precisa de regra nenhuma: só quer que de fora se possa ler, e só a classe possa gravar, mais de uma vez. Para isso existe public private(set):

<?php
class Conta
{
    public private(set) float $saldo = 0.0;

    public function depositar(float $valor): void
    {
        $this->saldo += $valor;
    }
}
$c = new Conta();
$c->depositar(50);
echo $c->saldo, "\n";
$c->saldo = 1000000;
$ php conta.php
50

Fatal error: Uncaught Error: Cannot modify private(set) property Conta::$saldo from global scope in /home/ana/poo/conta.php:14
Stack trace:
#0 {main}
  thrown in /home/ana/poo/conta.php on line 14

Diferente do readonly, o método depositar pode mudar o saldo quantas vezes quiser. Mais sobre isso em Visibilidade.


Qual usar?

Você quer…UseExemplo
Um valor que nunca muda depois de criadoreadonlynúmero do pedido, CPF, data de criação
Um objeto inteiro que nunca mudareadonly classPreco, Ponto, Endereco
Uma "versão alterada" de um objeto readonlymétodo comAlgo() com clone($this, [...])$preco->comValor(9.9)
Ler de fora, mudar só por dentro (várias vezes)public private(set)saldo, estoque, contador
Validar ou limpar todo valor gravadohook setpreço não negativo, e-mail minúsculo
Um valor calculado a partir de outroshook get (virtual)etiqueta, nome completo, total

Em PHP anterior ao 8.4 (sem hooks), o equivalente é uma propriedade private com métodos getPreco() e setPreco(), ou os mágicos __get/__set. Em código novo, prefira os hooks: o editor enxerga a propriedade e quem usa a classe não precisa decorar nomes de métodos.


Quando dá errado

Readonly sem tipo ou com valor padrão
<?php
class Pedido
{
    public readonly $numero;
}
$ php sem-tipo.php

Fatal error: Readonly property Pedido::$numero must have type in /home/ana/poo/sem-tipo.php on line 4
Stack trace:
#0 {main}

O que significa: "a propriedade readonly precisa ter tipo". Com public readonly int $numero = 1; o erro seria Readonly property Pedido::$numero cannot have default value ("não pode ter valor padrão"): um valor fixo para sempre é uma constante, não uma propriedade.

Como consertar: declare o tipo e grave o valor no construtor.

Ler antes de gravar
<?php
class Pedido
{
    public readonly int $numero;
}
$p = new Pedido();
echo $p->numero;
$ php antes.php

Fatal error: Uncaught Error: Typed property Pedido::$numero must not be accessed before initialization in /home/ana/poo/antes.php:7
Stack trace:
#0 {main}
  thrown in /home/ana/poo/antes.php on line 7

O que significa: "a propriedade tipada não pode ser acessada antes de ser inicializada". A classe não tem construtor, então $numero nunca recebeu valor, e uma readonly não tem valor padrão.

Como consertar: grave toda propriedade readonly no construtor (a promoção public readonly int $numero dentro do __construct já faz isso).

Gravar numa propriedade só com get
<?php
class Produto
{
    public string $etiqueta {
        get => "x";
    }
}
$p = new Produto();
$p->etiqueta = "y";
$ php so-get.php

Fatal error: Uncaught Error: Property Produto::$etiqueta is read-only in /home/ana/poo/so-get.php:9
Stack trace:
#0 {main}
  thrown in /home/ana/poo/so-get.php on line 9

O que significa: uma propriedade virtual com só get é "somente leitura": não há onde guardar o valor.

Como consertar: grave nas propriedades de onde ela é calculada (aqui, nome e preco) ou acrescente um set.

Hook com readonly
<?php
class Produto
{
    public readonly string $nome {
        get => "a";
    }
}
$ php hook-readonly.php

Fatal error: Hooked properties cannot be readonly in /home/ana/poo/hook-readonly.php on line 4
Stack trace:
#0 {main}

O que significa: "propriedades com hooks não podem ser readonly". As duas ideias não se misturam (e por isso uma readonly class também não aceita hooks).

Como consertar: escolha uma. Para "valida uma vez e nunca muda", use readonly e valide no construtor. Para "valida sempre e pode mudar", use o hook.

clone … with não existe

O recurso é conhecido como "clone with", mas a sintaxe é a de uma função: clone($obj, [...]). Escrever clone $a with ["valor" => 1] dá Parse error: syntax error, unexpected identifier "with". E o clone com valores existe só no PHP 8.5; em versões anteriores, use new static(...).


Referência rápida

EscritaSignificadoVersão
public readonly int $xGravada uma vez, de dentro da classe; lida de qualquer lugar8.1
readonly class NomeTodas as propriedades são readonly; sem propriedades dinâmicas8.2
clone($obj, ["x" => 1])Copia e grava novos valores (respeitando a visibilidade)8.5
__clone() regravando readonlyPermitido só dentro do __clone8.3
public private(set) float $xLê de fora, grava só por dentro8.4
set(tipo $v) { … $this->x = $v; }Roda a cada gravação8.4
set => expr($value);Forma curta: guarda o resultado8.4
get => expr;Roda a cada leitura8.4

Missão final

Missão: o hook set da nota aceita qualquer número, e a Ana ficou com nota 11. Complete o hook para lançar new ValueError("Nota inválida: $valor") quando a nota for menor que 0 ou maior que 10. A saída deve mostrar a mensagem e a nota da Ana deve continuar 9.5.

<?php
class Aluno
{
    public float $nota {
        set(float $valor) {
            // recuse notas menores que 0 ou maiores que 10
            $this->nota = $valor;
        }
    }

    public function __construct(public string $nome, float $nota)
    {
        $this->nota = $nota;
    }
}

$ana = new Aluno("Ana", 9.5);
echo "$ana->nome: $ana->nota\n";

try {
    $ana->nota = 11;
} catch (ValueError $e) {
    echo $e->getMessage(), "\n";
}
echo "$ana->nome: $ana->nota\n";
<?php
class Aluno
{
    public float $nota {
        set(float $valor) {
            if ($valor < 0 || $valor > 10) {
                throw new ValueError("Nota inválida: $valor");
            }
            $this->nota = $valor;
        }
    }

    public function __construct(public string $nome, float $nota)
    {
        $this->nota = $nota;
    }
}

$ana = new Aluno("Ana", 9.5);
echo "$ana->nome: $ana->nota\n";

try {
    $ana->nota = 11;
} catch (ValueError $e) {
    echo $e->getMessage(), "\n";
}
echo "$ana->nome: $ana->nota\n";

Exercícios

1. Somente leitura

Complete para que o CPF possa ser lido, mas nunca alterado depois de criado:

public string $cpf

2. Classe inteira

Complete para que todas as propriedades da classe Endereco sejam readonly:

class Endereco

3. Cópia com valor novo (PHP 8.5)

Dentro do método comMoeda(string $moeda), complete para devolver uma cópia com a moeda nova:

return ;

4. A classe Preco é readonly. No código principal (fora da classe), a Ana escreve $b = clone($a, ["valor" => 9.9]);. O que acontece?

5. Num hook set => …; (forma curta), como se chama a variável com o valor que está chegando?

6. Uma propriedade só com get => $this->nome . " " . $this->sobrenome; é:

7. O saldo deve ser lido de fora e alterado várias vezes, mas só pelos métodos da classe. Qual declaração usar?


Resumo

  • readonly (8.1): gravada uma vez, de dentro da classe; precisa de tipo, não tem valor padrão. Mudar dá Cannot modify readonly property.
  • Desde o 8.4, readonly também é protected(set): gravar de fora dá Cannot modify protected(set) readonly property … from global scope.
  • Readonly protege a propriedade, não o objeto guardado dentro dela.
  • readonly class (8.2): todas as propriedades readonly e nada de propriedades dinâmicas.
  • Para "mudar" um objeto readonly, crie outro: um método comAlgo() com clone($this, ["prop" => $novo]) (8.5) ou new static(...). De fora da classe, o clone com valores esbarra no protected(set).
  • Hooks (8.4): set valida ou limpa cada gravação; get calcula na leitura; só get = propriedade virtual. Hooks não combinam com readonly.
  • public private(set): lê de fora, grava só por dentro, quantas vezes quiser.