Voltar ao Blog
PHP11 min de leitura

PHP 8 moderno: 10 recursos que aposentam seu código legado

Enums, readonly, match, property hooks e mais. Um tour prático pelos recursos do PHP 8 que substituem padrões que a gente escreve no automático desde o PHP 5 — com o antes e o depois de cada um.

Neste artigo

Trabalho com PHP há mais de quinze anos. Nesse tempo aprendi que a parte difícil de uma linguagem nova não é aprender o que mudou — é desaprender o que você escrevia no automático.

O PHP 8 já está entre nós há um bom tempo, mas ainda abro código todo dia com getters e setters escritos à mão, constantes de classe fingindo ser enum e switch com break em todo caso. Não porque o time não conhece as novidades: porque o dedo já sabe digitar o jeito antigo antes de o cérebro lembrar do novo.

Esta é a lista que eu passo para quem está migrando. São dez recursos, cada um com o par “como era” e “como fica”, e a versão exata em que entrou — porque tentar usar property hooks num projeto travado no 8.1 só gera frustração.

1. Promoção de propriedades no construtor (8.0)

O construtor mais chato de escrever em PHP sempre foi o de um objeto de valor: declarar a propriedade, declarar o parâmetro, atribuir um no outro. Três linhas para dizer a mesma coisa.

Antes — PHP 7.4
final class Coordenada
{
    private float $latitude;
    private float $longitude;

    public function __construct(float $latitude, float $longitude)
    {
        $this->latitude = $latitude;
        $this->longitude = $longitude;
    }
}
Depois — PHP 8.0
final class Coordenada
{
    public function __construct(
        private float $latitude,
        private float $longitude,
    ) {}
}

Repare na vírgula depois do último parâmetro. Vírgula final em lista de argumentos entrou no 8.0 também, e é o tipo de detalhe que deixa o git diff muito mais limpo: adicionar um parâmetro passa a mexer em uma linha só.

Um aviso: promoção só funciona no construtor, e não combina com propriedades que precisam de valor calculado. Se você precisa transformar o argumento antes de guardar, volte para a forma longa — ou use um construtor nomeado.

Quando a promoção não serve
final class Email
{
    private function __construct(
        public readonly string $valor,
    ) {}

    public static function de(string $entrada): self
    {
        $normalizado = strtolower(trim($entrada));

        if (! filter_var($normalizado, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException("E-mail inválido: {$entrada}");
        }

        return new self($normalizado);
    }
}

2. readonly (8.1) e o fim do getter burro

Metade dos getters que escrevemos existe por um motivo só: impedir que alguém mude a propriedade depois de construída. readonly resolve isso na declaração.

Antes
final class Pedido
{
    private string $id;

    public function __construct(string $id)
    {
        $this->id = $id;
    }

    public function getId(): string
    {
        return $this->id;
    }
}

$pedido->getId();
Depois — PHP 8.1
final class Pedido
{
    public function __construct(
        public readonly string $id,
    ) {}
}

$pedido->id;
$pedido->id = 'outro'; // Error: Cannot modify readonly property

O ganho não é economizar linhas — é que a imutabilidade vira garantia do runtime, não uma convenção que o próximo desenvolvedor pode ignorar.

No PHP 8.2 chegou readonly class, que aplica o modificador a todas as propriedades de uma vez:

PHP 8.2
final readonly class Dinheiro
{
    public function __construct(
        public int $centavos,
        public string $moeda,
    ) {}

    public function somar(self $outro): self
    {
        if ($this->moeda !== $outro->moeda) {
            throw new DomainException('Moedas diferentes');
        }

        return new self($this->centavos + $outro->centavos, $this->moeda);
    }
}

Uma pegadinha que pega todo mundo uma vez: readonly impede a reatribuição, não a mutação. Se a propriedade é um array, você não pode trocá-lo por outro — mas se for um objeto, nada impede de chamar um método que muda o estado interno dele. readonly é raso.

3. Enums (8.1) — o fim das constantes fingindo tipo

Este é o recurso que mais muda código no dia a dia. Todo projeto PHP tem uma classe cheia de constantes com um comentário explicando o que cada uma significa, e uma função static para validar se o valor recebido é um dos permitidos.

Antes
final class StatusPedido
{
    public const PENDENTE = 'pendente';
    public const PAGO = 'pago';
    public const CANCELADO = 'cancelado';

    public static function valido(string $status): bool
    {
        return in_array($status, [self::PENDENTE, self::PAGO, self::CANCELADO], true);
    }
}

function processar(string $status): void
{
    if (! StatusPedido::valido($status)) {
        throw new InvalidArgumentException('Status inválido');
    }
    // ...
}

O problema: a assinatura diz string. Nada impede alguém de passar 'PENDENTE', 'Pendente' ou 'qualquer coisa' — o erro só aparece em runtime, e só se você lembrou de chamar o validador.

Depois — PHP 8.1
enum StatusPedido: string
{
    case Pendente = 'pendente';
    case Pago = 'pago';
    case Cancelado = 'cancelado';

    public function rotulo(): string
    {
        return match ($this) {
            self::Pendente => 'Aguardando pagamento',
            self::Pago => 'Pagamento confirmado',
            self::Cancelado => 'Cancelado',
        };
    }

    public function podeSerCancelado(): bool
    {
        return $this === self::Pendente;
    }
}

function processar(StatusPedido $status): void
{
    // Se chegou aqui, o valor é válido. Garantido pelo motor.
}

Agora a validação é responsabilidade do type system, não sua. E como enums podem ter métodos, o comportamento associado a cada estado mora junto com o estado — em vez de espalhado por ifs pelo código.

Enum puro × enum com backing

O : string acima faz dele um backed enum, que tem valor escalar e sabe converter:

Conversão de e para o banco
StatusPedido::from('pago');        // StatusPedido::Pago
StatusPedido::tryFrom('inexistente'); // null, em vez de exceção
StatusPedido::Pago->value;         // 'pago'
StatusPedido::cases();             // array com todos os casos

Use tryFrom quando o dado vem de fora (request, fila, API) e você quer tratar o inválido. Use from quando o dado vem do seu banco e um valor desconhecido significa que algo está corrompido — aí a exceção é bem-vinda.

Se você não precisa persistir o valor, use enum puro (sem : string). É mais restritivo e mais claro sobre a intenção.

4. match (8.0) — o switch que não esquece o break

Antes
switch ($codigo) {
    case 200:
    case 201:
        $resultado = 'sucesso';
        break;
    case 404:
        $resultado = 'não encontrado';
        break;
    default:
        $resultado = 'erro';
}
Depois
$resultado = match ($codigo) {
    200, 201 => 'sucesso',
    404 => 'não encontrado',
    default => 'erro',
};

Três diferenças que importam:

  1. É expressão, não instrução. Retorna valor, então dá para atribuir direto.
  2. Compara com ===, não ==. match ('1') não casa com case 1. Isso já evitou bug em produção mais de uma vez.
  3. Explode se nada casar. Sem default, um valor não previsto lança UnhandledMatchError em vez de seguir silenciosamente.

O item 3 é o mais valioso quando combinado com enum: se você adicionar um caso novo ao enum e esquecer de tratá-lo no match, o erro aparece — em vez de o código cair no default e fazer a coisa errada em silêncio.

Também dá para usar match(true) no lugar de uma escada de elseif:

Escada de condições
$faixa = match (true) {
    $idade < 13 => 'criança',
    $idade < 18 => 'adolescente',
    $idade < 60 => 'adulto',
    default => 'idoso',
};

5. Argumentos nomeados (8.0)

O maior inimigo da legibilidade em PHP sempre foi a chamada com booleanos soltos. Você lê salvar($usuario, true, false, true) e não faz ideia do que está ligando.

Antes e depois
// Antes: o que são esses três booleanos?
$relatorio = gerar($pedidos, true, false, true);

// Depois: a chamada se documenta sozinha
$relatorio = gerar(
    pedidos: $pedidos,
    incluirCancelados: true,
    agruparPorCliente: false,
    enviarPorEmail: true,
);

Bônus: dá para pular parâmetros opcionais no meio da assinatura, o que antes obrigava a repetir os valores padrão só para alcançar o último argumento.

Pulando o meio
function conectar(
    string $host,
    int $porta = 3306,
    string $charset = 'utf8mb4',
    bool $persistente = false,
) {}

// Antes: precisava repetir porta e charset
conectar('localhost', 3306, 'utf8mb4', true);

// Agora:
conectar('localhost', persistente: true);

Um cuidado: ao usar argumento nomeado, o nome do parâmetro vira parte do contrato público. Renomear um parâmetro passa a ser breaking change. Em bibliotecas, pense duas vezes antes de considerar isso seguro.

6. Operador nullsafe (8.0)

Antes
$cidade = null;

if ($usuario !== null) {
    $endereco = $usuario->getEndereco();
    if ($endereco !== null) {
        $cidade = $endereco->getCidade();
    }
}
Depois
$cidade = $usuario?->getEndereco()?->getCidade();

A cadeia inteira curto-circuita no primeiro null. Combina bem com o operador de coalescência para dar um padrão:

$cidade = $usuario?->getEndereco()?->getCidade() ?? 'Não informada';

Vale a ressalva: ?-> esconde a ausência de dado. Se null ali significa “estado inválido que não deveria acontecer”, é melhor deixar explodir do que mascarar com nullsafe.

7. Sintaxe de callable de primeira classe (8.1)

Passar um método como callback sempre exigiu uma string ou um array com sintaxe que ninguém memoriza.

Antes e depois
// Antes
$nomes = array_map([$this, 'formatarNome'], $usuarios);
$ativos = array_filter($usuarios, 'App\Helpers\estaAtivo');

// Depois — PHP 8.1
$nomes = array_map($this->formatarNome(...), $usuarios);
$ativos = array_filter($usuarios, estaAtivo(...));

Além de mais curto, é verificável estaticamente: a IDE encontra o método, o PHPStan valida a assinatura, e renomear com refactor automático funciona. Com string, nada disso acontece.

8. Union types, intersection types e never (8.0 / 8.1 / 8.1)

Tipos mais expressivos
// Union (8.0): aceita mais de um tipo
function normalizar(int|string $entrada): string
{
    return (string) $entrada;
}

// Intersection (8.1): precisa satisfazer as duas interfaces
function processar(Countable&Iterator $colecao): void
{
    echo count($colecao);
    foreach ($colecao as $item) { /* ... */ }
}

// never (8.1): esta função nunca retorna
function abortar(string $mensagem): never
{
    throw new RuntimeException($mensagem);
}

never parece detalhe cosmético, mas muda a análise estática: o PHPStan passa a saber que o código depois da chamada é inalcançável, e para de reclamar de “variável possivelmente indefinida” em ramos que nunca continuam.

9. new em inicializadores (8.1)

Injeção de dependência com valor padrão sempre foi desconfortável em PHP: você não podia instanciar no parâmetro, então o padrão virava null e o construtor ficava com um ?? dentro.

Antes e depois
// Antes
public function __construct(?LoggerInterface $logger = null)
{
    $this->logger = $logger ?? new NullLogger();
}

// Depois — PHP 8.1
public function __construct(
    private LoggerInterface $logger = new NullLogger(),
) {}

O tipo do parâmetro deixa de ser anulável, o que remove uma verificação de null de todo lugar que usa $this->logger.

10. Property hooks e visibilidade assimétrica (8.4)

O 8.4 trouxe o recurso que mais aproxima o PHP de linguagens como C# e Kotlin: propriedades com comportamento, sem virar método.

Property hooks — PHP 8.4
final class Usuario
{
    public string $nomeCompleto {
        get => trim("{$this->nome} {$this->sobrenome}");
    }

    public string $email {
        set (string $valor) {
            $normalizado = strtolower(trim($valor));

            if (! filter_var($normalizado, FILTER_VALIDATE_EMAIL)) {
                throw new InvalidArgumentException('E-mail inválido');
            }

            $this->email = $normalizado;
        }
    }

    public function __construct(
        public string $nome,
        public string $sobrenome,
        string $email,
    ) {
        $this->email = $email; // passa pelo hook
    }
}

$usuario->nomeCompleto;          // computado na hora
$usuario->email = '  A@B.COM  '; // vira 'a@b.com', validado

E a visibilidade assimétrica resolve o caso mais comum de todos: leitura pública, escrita restrita.

Visibilidade assimétrica — PHP 8.4
final class Carrinho
{
    // Qualquer um lê; só a própria classe escreve.
    public private(set) int $total = 0;

    public function adicionar(Item $item): void
    {
        $this->total += $item->preco;
    }
}

$carrinho->total;      // ok
$carrinho->total = 99; // Error: Cannot modify private(set) property

Isso aposenta a dupla getter público + propriedade privada que existia só para controlar quem escreve. Menos código, mesma garantia.

Quando não migrar

Nada disso vale se quebrar o que está funcionando. Alguns freios que eu aplico:

Não refatore código estável só para modernizar. Uma classe que não muda há dois anos e passa nos testes não fica melhor porque virou readonly. Modernize o que você já está tocando por outro motivo.

Enum não é substituto universal de constante. Se o conjunto de valores muda com frequência ou vem do banco, enum vira engessamento — você passa a precisar de deploy para adicionar uma opção.

match sem default é uma escolha, não um descuido. Em código que recebe entrada externa, UnhandledMatchError na cara do usuário é pior que um fallback. Use a rigidez onde você controla os valores.

Confira a versão do runtime antes. Property hooks e private(set) são 8.4. readonly class é 8.2. Enums são 8.1. Escrever para uma versão que a infraestrutura não roda gera um fatal error no deploy, não um aviso.

Checklist para começar

Se você quer aplicar isso num projeto existente sem virar tudo de cabeça para baixo, esta é a ordem que dá mais retorno com menos risco:

  1. Rode o Rector com o set PHP_80, PHP_81 e assim por diante. Ele faz a maioria dessas conversões automaticamente e você revisa o diff.
  2. Comece por promoção de construtor e match — são as mudanças mais mecânicas e de menor risco.
  3. Troque as classes de constantes por enums, uma de cada vez, começando pelas que já têm método de validação.
  4. Adicione readonly aos objetos de valor. Se algum teste quebrar, você acabou de descobrir uma mutação que não deveria existir.
  5. Ligue o PHPStan no nível 6 ou acima. Ele encontra os lugares onde os tipos novos deixariam o código mais preciso.

O objetivo não é usar todo recurso disponível. É deixar o compilador carregar o peso que hoje está nos seus testes e na sua memória.

PHPPHP 8Boas PráticasRefatoração

Leia também