Tem uma RFC em votação no PHP que resolve aquele conflito entre readonly e constructor promotion. Se você usa PHP moderno, provavelmente já esbarrou nisso.
O cenário: você declara uma propriedade promoted readonly no construtor. Limpo, conciso, exatamente como o PHP moderno incentiva. Mas aí precisa validar ou transformar o valor antes de guardar. E não pode, porque readonly trava a propriedade na primeira atribuição, que acontece automaticamente na promoção.
Resultado: você abandona o promoted e volta a declarar a propriedade separada, escrever a atribuição manual, perder a concisão que promoted trouxe. Duas features que deveriam trabalhar juntas se anulam.
A RFC do Robert Landers propõe permitir exatamente uma reatribuição de propriedades promoted readonly dentro do construtor. Não é um relaxamento geral do readonly. A restrição continua valendo fora do construtor. O que muda é que o construtor pode fazer a promoção automática e depois sobrescrever com o valor validado.
Tem regra clara: uma reatribuição, só no construtor da classe que declarou a propriedade, só em $this. Asymmetric visibility continua funcionando. A implementação reutiliza uma flag interna que já existia (ISPROPREINITABLE).
A votação está aberta até 7 de abril. E o que me chama atenção é que não é uma feature nova, é uma correção de ergonomia. O PHP tem duas features excelentes que, combinadas, criam atrito. Esse tipo de polimento é o que faz diferença no dia a dia de quem escreve código de produção.
Se sua equipe usa readonly com promoted properties em DTOs, value objects ou qualquer classe de domínio, vale acompanhar essa votação.
Uma reatribuição, só na cadeia do construtor
A regra proposta é estreita. A propriedade precisa ser readonly e ter sido promovida no construtor (CPP). A reatribuição é permitida enquanto um construtor da classe dona do CPP está na stack, inclusive via método ou closure chamada dali. Só uma vez. Só em $this. private(set) e protected(set) continuam valendo dentro dessa cadeia.
Chamar __construct() de novo no objeto já construído não reabre a janela. Depois que a primeira construção termina, a propriedade já não está não-inicializada, e o Error de readonly volta. ReflectionClass::newInstanceWithoutConstructor() seguido do primeiro __construct() explícito ainda permite a reatribuição, porque aquela é a primeira construção. Uma segunda chamada falha.
Filho não herda a janela do pai. A posse é da classe que promoveu. Se o filho declara a mesma propriedade sem CPP, o pai continua podendo reatribuir uma vez. Se o filho também promove, o CPP do filho roda primeiro e o parent::__construct() que tenta inicializar de novo quebra. Setar no filho antes de chamar o pai também quebra, pelo mesmo motivo: o slot deixa de estar vazio.
Isso resolve o DTO que faz trim, strtolower, abs, ou aplica um default com ??. Não resolve inicialização em vários passos com dois writes. O segundo write continua Error. ++, --, += e .= contam como a única reatribuição.
Quando a declaração manual continua mais clara
A própria RFC lista casos em que abandonar promotion segue melhor. Parâmetro nullable que vira propriedade non-nullable. PHPDoc que precisa viver na propriedade (non-empty-string, lowercase-string). Inicialização longa, com várias derivações a partir do mesmo input.
Property hooks no PHP 8.4 normalizam em toda atribuição. Sem private(set) extra, perdem a garantia write-once do readonly. Semântica diferente, custo diferente. Trocar readonly por hook só para manter o CPP é troca de contrato. Útil quando a normalização deve valer depois do construtor. Ruim quando o objeto precisa nascer imutável.
Se a votação recusar, o workaround continua o de sempre: declarar a propriedade, processar o parâmetro, atribuir uma vez. Verboso. Legível. Já funciona em 8.1. Bibliotecas de domínio que precisam rodar em 8.3 e 8.6 ao mesmo tempo continuam nesse desenho, mesmo que a RFC passe, até o piso de PHP subir.
Há um critério simples para escolher. Se parâmetro e propriedade têm o mesmo tipo e a transformação cabe numa linha, a reatribuição proposta reduz ruído. Se o tipo diverge ou a transformação tem ramos, a declaração tradicional documenta o contrato melhor do que um segundo write escondido no corpo.
Analisadores e o hábito do time
Ferramentas estáticas e IDEs hoje marcam o segundo write em readonly como erro. Se a RFC passar, elas precisam entender a janela. Até lá, habilitar a sintaxe num CI com PHP 8.5 quebra o build. Psalm, PHPStan e o parser do editor não votam na RFC. Eles seguem o runtime que a esteira usa.
Code review precisa de uma pergunta só: este write é a normalização da promoção, ou alguém está usando o construtor como setter? A RFC não quer o segundo. Um ++ no contador promovido passa fácil numa leitura rápida e consome a única permissão.
__clone() já podia reinicializar readonly via IS_PROP_REINITABLE. A proposta não mexe nisso. A janela de clone e a janela de construtor são marcadores distintos de propósito, para um não vazar no outro. Misturar clone, promotion e reatribuição no mesmo objeto pede teste no domínio, não analogia com "readonly já era flexível no clone".
Eu pediria um teste por classe que use a nova janela: construir com input sujo, afirmar o valor normalizado, tentar um segundo write (Error), clonar se o objeto for clonável. Sem isso, o time só descobre o limite quando um factory chamar o construtor duas vezes.
Esperar o runtime da equipe, não o texto da RFC
A votação ia até 7 de abril e pedia dois terços. Texto aprovado ainda precisa da minor que incorpore o patch. A RFC aponta PHP 8.6 ou posterior. Bibliotecas de DTO que adotarem a sintaxe no dia do merge forçam o piso da aplicação. Em vendor compartilhado entre 8.4 e 8.6, a declaração manual permanece o menor denominador.
Acompanhar a votação faz sentido se o time já sofre com o atrito em value objects e já tem um padrão de promotion. Abrir um refactor amplo "porque a RFC existe" gasta o mesmo esforço que a declaração manual, com o risco extra de o analisador e o runtime da esteira rejeitarem o código.
Há também o custo de ensinar a regra. "Readonly não muda, exceto uma vez no construtor da classe que promoveu, inclusive em método chamado dali, nunca no filho que não é dono do CPP" não cabe num slide. Cabe num exemplo no guia interno, se a feature existir no PHP que vocês de fato rodam.
Enquanto o voto não vira patch na imagem de produção, o desenho estável continua o mesmo: promotion quando o valor chega pronto, declaração tradicional quando o construtor precisa trabalhar.


