Nesta aula, vamos explorar um recurso moderno do PHP: o modificador readonly para propriedades de classes, introduzido na versão 8.1. Esse recurso permite que você defina propriedades que só podem ser atribuídas uma vez, geralmente no construtor, e depois se tornam imutáveis. Isso é fundamental para construir objetos imutáveis e value objects, que são pilares de um código mais seguro, previsível e fácil de testar.

Vamos entender como o readonly funciona, quais são suas limitações e como ele se encaixa em padrões de design como imutabilidade e value objects. Veremos exemplos práticos que mostram na prática como evitar efeitos colaterais indesejados e tornar seu código mais robusto.

readonly (PHP 8.1)

O modificador readonly pode ser aplicado a propriedades de classes para garantir que elas sejam inicializadas apenas uma vez. A partir do PHP 8.1, você pode declarar uma propriedade como readonly no construtor ou diretamente na declaração da propriedade. Uma vez atribuída, qualquer tentativa de modificar a propriedade gerará um erro fatal.

Importante: propriedades readonly só podem ser declaradas em classes (não em interfaces ou traits) e devem ser tipadas (exceto se o tipo for inferido pelo construtor). Além disso, elas não podem ser static. O PHP 8.1 também permite que você use readonly em construtores promovidos, simplificando a sintaxe.

class User {
    public readonly string $name;
    public readonly int $age;

    public function __construct(string $name, int $age) {
        $this->name = $name;
        $this->age = $age;
    }
}

$user = new User('Alice', 30);
echo $user->name; // Alice
// $user->name = 'Bob'; // Erro fatal: Cannot modify readonly property

No exemplo acima, as propriedades name e age são readonly e só podem ser definidas no construtor. Qualquer tentativa de alterá-las após a construção resulta em erro. Isso garante que o estado do objeto não mude depois de criado.

Objetos imutáveis

Um objeto imutável é aquele cujo estado não pode ser alterado após sua criação. Embora o PHP não tenha suporte nativo a objetos imutáveis como algumas linguagens, o uso de propriedades readonly é um grande passo nessa direção. Para que um objeto seja completamente imutável, todas as suas propriedades devem ser readonly, e o objeto não deve expor métodos que modifiquem seu estado interno.

Objetos imutáveis trazem benefícios como segurança em ambientes concorrentes (embora PHP não tenha concorrência real, ajuda em cenários de cache), previsibilidade e facilidade de teste. Eles também evitam bugs causados por alterações inesperadas de estado.

class Point {
    public readonly int $x;
    public readonly int $y;

    public function __construct(int $x, int $y) {
        $this->x = $x;
        $this->y = $y;
    }

    // Método que retorna um novo objeto em vez de modificar o atual
    public function move(int $dx, int $dy): Point {
        return new Point($this->x + $dx, $this->y + $dy);
    }
}

$p1 = new Point(1, 2);
$p2 = $p1->move(3, 4);
echo $p1->x; // 1 (inalterado)
echo $p2->x; // 4

A classe Point é imutável: suas propriedades são readonly e o método move retorna um novo objeto. Isso evita efeitos colaterais e facilita o raciocínio sobre o código.

Value objects

Value objects são objetos que representam um valor conceitual, como um endereço de e-mail, um CPF, uma data ou um ponto geométrico. Eles são imutáveis por definição e se comparam pelo seu valor, não por identidade. No PHP, podemos implementar value objects usando classes com propriedades readonly e sobrescrevendo o método __toString() e implementando __equals() ou usando a interface Equatable do PHP 8.1+.

Value objects ajudam a encapsular regras de validação e formatação, tornando o código mais expressivo e seguro. Por exemplo, um value object para e-mail pode garantir que o endereço seja válido no momento da criação.

class Email {
    public readonly string $value;

    public function __construct(string $value) {
        if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("Invalid email: $value");
        }
        $this->value = $value;
    }

    public function equals(Email $other): bool {
        return $this->value === $other->value;
    }

    public function __toString(): string {
        return $this->value;
    }
}

$email1 = new Email('user@example.com');
$email2 = new Email('user@example.com');
echo $email1->equals($email2) ? 'iguais' : 'diferentes'; // iguais

No exemplo, a classe Email é um value object: é imutável (propriedade readonly), valida o valor no construtor e fornece um método equals() para comparação por valor.

Exemplos

Vamos ver um exemplo mais completo que combina readonly, imutabilidade e value objects em um sistema de pedidos.

class Money {
    public readonly float $amount;
    public readonly string $currency;

    public function __construct(float $amount, string $currency) {
        if ($amount < 0) {
            throw new InvalidArgumentException("Amount cannot be negative");
        }
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public function add(Money $other): Money {
        if ($this->currency !== $other->currency) {
            throw new InvalidArgumentException("Currency mismatch");
        }
        return new Money($this->amount + $other->amount, $this->currency);
    }

    public function equals(Money $other): bool {
        return $this->amount === $other->amount && $this->currency === $other->currency;
    }

    public function __toString(): string {
        return number_format($this->amount, 2) . ' ' . $this->currency;
    }
}

class OrderLine {
    public readonly string $product;
    public readonly Money $price;
    public readonly int $quantity;

    public function __construct(string $product, Money $price, int $quantity) {
        $this->product = $product;
        $this->price = $price;
        $this->quantity = $quantity;
    }

    public function total(): Money {
        return new Money($this->price->amount * $this->quantity, $this->price->currency);
    }
}

$price = new Money(10.50, 'BRL');
$line = new OrderLine('Caneta', $price, 3);
echo $line->total(); // 31.50 BRL

Neste exemplo, Money e OrderLine são imutáveis. O método add retorna um novo objeto Money, garantindo que o original não seja alterado. Isso torna o código mais previsível e seguro.

Boas práticas

Ao usar readonly, lembre-se de que a imutabilidade é superficial: se uma propriedade readonly for um objeto, o objeto em si pode ser mutável. Para imutabilidade profunda, todos os objetos aninhados também devem ser imutáveis. Prefira sempre value objects para representar conceitos do domínio, pois eles encapsulam regras e evitam duplicação de lógica.

Outra dica: evite getters que retornam referências a objetos mutáveis internos. Em vez disso, retorne cópias ou objetos imutáveis. Isso mantém a integridade do objeto.

Referências

Exercícios

  1. Crie uma classe Person com propriedades readonly name e age. Instancie e tente modificar a idade após a criação. O que acontece?

    ✓ Resposta: O PHP lança um erro fatal: "Cannot modify readonly property Person::$age".
  2. Implemente um value object CPF que valide o formato (apenas números, 11 dígitos) e seja imutável. Crie um método formatado() que retorne o CPF no formato XXX.XXX.XXX-XX.

    ✓ Resposta:
    class CPF {
        public readonly string $value;
    
        public function __construct(string $value) {
            $digits = preg_replace('/\D/', '', $value);
            if (strlen($digits) !== 11 || !ctype_digit($digits)) {
                throw new InvalidArgumentException("CPF inválido");
            }
            $this->value = $digits;
        }
    
        public function formatado(): string {
            return substr($this->value, 0, 3) . '.' .
                   substr($this->value, 3, 3) . '.' .
                   substr($this->value, 6, 3) . '-' .
                   substr($this->value, 9, 2);
        }
    
        public function __toString(): string {
            return $this->formatado();
        }
    }
  3. Modifique a classe Point da aula para que ela implemente um método distance(Point $other): float que retorne a distância euclidiana entre dois pontos. O método não deve modificar os objetos.

    ✓ Resposta:
    class Point {
        public readonly int $x;
        public readonly int $y;
    
        public function __construct(int $x, int $y) {
            $this->x = $x;
            $this->y = $y;
        }
    
        public function distance(Point $other): float {
            $dx = $this->x - $other->x;
            $dy = $this->y - $other->y;
            return sqrt($dx * $dx + $dy * $dy);
        }
    }
  4. Explique por que objetos imutáveis são preferíveis em ambientes concorrentes, mesmo que o PHP não tenha concorrência real. Dê um exemplo de cenário onde a imutabilidade evitaria um bug.

    ✓ Resposta: Objetos imutáveis são seguros em concorrência porque não podem ser alterados após a criação, eliminando condições de corrida. Em PHP, mesmo sem threads, a imutabilidade é útil em cenários de cache: se um objeto mutável for armazenado em cache e depois modificado, outros lugares que usam o cache podem ler um estado inconsistente. Com imutabilidade, isso não ocorre.
  5. Crie uma função somaValores(Money ...$valores): Money que receba múltiplos objetos Money e retorne a soma total, assumindo que todos estão na mesma moeda. Use o método add existente.

    ✓ Resposta:
    function somaValores(Money ...$valores): Money {
        if (empty($valores)) {
            throw new InvalidArgumentException("Pelo menos um valor é necessário");
        }
        $total = new Money(0, $valores[0]->currency);
        foreach ($valores as $money) {
            $total = $total->add($money);
        }
        return $total;
    }