PHP Enums

Um pedido da lojinha da Ana pode estar Pendente, Pago ou Enviado, e nada além disso. Um tamanho de camiseta é P, M ou G. Um dia da semana é um de sete. Quando um valor só pode ser um de uma lista fechada, o PHP tem um tipo feito para isso: o enum (de enumeration, "enumeração", uma lista de opções). PHP 8.1+

Você vai aprender
  • O problema de guardar opções em strings soltas.
  • Como criar um enum puro e usar os seus casos.
  • cases(): a lista de todas as opções.
  • Enums com valor (string ou int), ->value, from() e tryFrom(), e o ValueError real.
  • Métodos dentro do enum e o match com enum.
  • Os erros mais comuns, com as mensagens reais.

Pré-requisitos: classes e objetos, tipos em parâmetros e match. Os exemplos rodam na máquina fictícia da Ana, na pasta ~/poo.


O problema: opções escritas como texto

Sem enum, a Ana guarda o status do pedido como string. Funciona… até alguém digitar diferente:

<?php
function descrever(string $status): string
{
    if ($status === "pago") {
        return "Pagamento confirmado";
    }
    return "Aguardando";
}
echo descrever("pago"), "\n";
echo descrever("Pago"), "\n";
echo descrever("pgo"), "\n";
$ php status-texto.php
Pagamento confirmado
Aguardando          « "Pago" com P maiúsculo: errado, e sem aviso
Aguardando          « "pgo": erro de digitação, e sem aviso

O PHP não tem como saber que "Pago" e "pgo" estão errados: para ele, são só textos. O pedido pago aparece como "Aguardando" e ninguém percebe. A ideia do enum é trocar o texto livre por uma lista fechada que o PHP conhece e confere.

A ideia: um cardápio, não um papel em branco

Num restaurante, se você escreve o pedido num papel em branco, pode sair "fejoada". Se aponta no cardápio, só dá para escolher o que existe. O enum é o cardápio: o PHP conhece a lista inteira e recusa qualquer coisa fora dela.


1. Enum puro

<?php
enum Status
{
    case Pendente;
    case Pago;
    case Enviado;
}

$s = Status::Pago;
var_dump($s);
echo $s->name, "\n";
var_dump($s === Status::Pago);
var_dump($s === Status::Enviado);
var_dump($s instanceof Status);
enum Status
Cria um enum chamado Status. Como uma classe, o nome começa com maiúscula.
case Pendente;
Cada case ("caso") é uma das opções. Aqui são três, e serão sempre só essas três.
Status::Pago
Para usar um caso: nome do enum, ::, nome do caso. É um valor que você guarda em variável, passa para funções, compara.
$s->name
Todo caso tem a propriedade name ("nome"), com o nome do caso como texto.
$s === Status::Pago
Compare casos com ===. Cada caso existe uma vez só, então "é o mesmo caso?" é sempre uma pergunta exata.
instanceof Status
Por baixo, cada caso é um objeto do tipo Status.
$ php status.php
enum(Status::Pago)       « o var_dump mostra que é um enum, e qual caso
Pago
bool(true)
bool(false)
bool(true)

O enum como tipo: o PHP confere por você

A grande vantagem aparece quando o enum vira o tipo do parâmetro. A mesma função de antes, agora exigindo um Status:

<?php
enum Status
{
    case Pendente;
    case Pago;
    case Enviado;
}

function descrever(Status $status): string
{
    if ($status === Status::Pago) {
        return "Pagamento confirmado";
    }
    return "Aguardando";
}
echo descrever(Status::Pago), "\n";
echo descrever(Status::Pendente), "\n";
echo descrever("pago"), "\n";
$ php descrever.php
Pagamento confirmado
Aguardando

Fatal error: Uncaught TypeError: descrever(): Argument #1 ($status) must be of type Status, string given, called in /home/ana/poo/descrever.php on line 18 and defined in /home/ana/poo/descrever.php:9
Stack trace:
#0 /home/ana/poo/descrever.php(18): descrever('pago')
#1 {main}
  thrown in /home/ana/poo/descrever.php on line 9

"O argumento nº 1 precisa ser do tipo Status, foi passada uma string". Agora o texto solto não passa. E um erro de digitação no caso também é pego na hora:

$ php digitacao.php

Fatal error: Uncaught Error: Undefined constant Status::Pgo in /home/ana/poo/digitacao.php:8
Stack trace:
#0 {main}
  thrown in /home/ana/poo/digitacao.php on line 8

(O programa tinha echo Status::Pgo->name;. Para o PHP, os casos funcionam como constantes do enum, daí "constante indefinida".) Compare com a versão de strings, em que "pgo" passava em silêncio.

cases(): todas as opções

Todo enum ganha o método estático cases(), que devolve um array com todos os casos, na ordem em que foram escritos. Útil para montar um menu, um <select> de formulário, um relatório:

<?php
enum Status
{
    case Pendente;
    case Pago;
    case Enviado;
}

foreach (Status::cases() as $caso) {
    echo "- ", $caso->name, "\n";
}
echo count(Status::cases()), " opções\n";
$ php cases.php
- Pendente
- Pago
- Enviado
3 opções

Missão: crie um enum Tamanho com os casos P, M e G. O resto do código já está pronto: ele guarda Tamanho::M e mostra o nome e quantos casos existem.

<?php
// crie o enum Tamanho aqui


$camiseta = Tamanho::M;
echo "Tamanho: ", $camiseta->name, "\n";
echo "Casos: ", count(Tamanho::cases()), "\n";
<?php
enum Tamanho
{
    case P;
    case M;
    case G;
}

$camiseta = Tamanho::M;
echo "Tamanho: ", $camiseta->name, "\n";
echo "Casos: ", count(Tamanho::cases()), "\n";

2. Enum com valor (backed enum)

O enum puro vive só dentro do programa. Mas o status do pedido precisa ser guardado num banco de dados, mandado num JSON, recebido de um formulário. Nesses lugares só existem textos e números. Para isso, cada caso pode ter um valor associado, uma string ou um int. Em inglês, esse tipo se chama backed enum ("enum com lastro").

<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
}

echo Status::Pago->name, "\n";
echo Status::Pago->value, "\n";
var_dump(Status::from("enviado"));
var_dump(Status::tryFrom("cancelado"));
echo json_encode(["pedido" => 1001, "status" => Status::Pago]), "\n";
foreach (Status::cases() as $caso) {
    echo $caso->name, " = ", $caso->value, "\n";
}
enum Status: string
O : string diz que todo caso tem um valor do tipo string. Pode ser : int. Nenhum outro tipo é aceito.
case Pago = "pago";
O caso Pago tem o valor "pago". Os valores precisam ser diferentes entre si.
->value
O valor do caso ("pago"). É o que você grava no banco ou manda no JSON.
Status::from("enviado")
O caminho de volta: "de" um valor, encontra o caso. Se o valor não existir, lança um erro.
Status::tryFrom("cancelado")
"Tenta de": igual ao from, mas devolve null em vez de erro.
json_encode(…)
Um enum com valor vira o próprio valor no JSON (veja JSON).
$ php status-valor.php
Pago                          « name: o nome do caso, no código
pago                          « value: o valor, para fora do programa
enum(Status::Enviado)         « from achou o caso
NULL                          « tryFrom não achou: null
{"pedido":1001,"status":"pago"}
Pendente = pendente
Pago = pago
Enviado = enviado
name × value

name é para o programador (o nome no código, como Pago). value é para o mundo de fora (banco, JSON, URL). Assim você pode renomear um caso no código sem mexer nos dados já gravados, desde que mantenha o valor.

from × tryFrom: quando o valor não existe

Valores de fora do programa podem vir errados. Veja o que o from faz com um valor que não está no enum (repare: "Pago" com maiúscula é diferente de "pago"):

<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
}
var_dump(Status::from("Pago"));
$ php from-erro.php

Fatal error: Uncaught ValueError: "Pago" is not a valid backing value for enum Status in /home/ana/poo/from-erro.php:8
Stack trace:
#0 /home/ana/poo/from-erro.php(8): Status::from('Pago')
#1 {main}
  thrown in /home/ana/poo/from-erro.php on line 8

""Pago" não é um valor válido para o enum Status". É um ValueError, uma exceção: dá para capturar com try/catch. Na prática, escolha assim:

UseQuandoValor inválido
from($v)O valor deveria estar certo (veio do seu próprio banco). Valor errado é um bug que precisa aparecer.ValueError
tryFrom($v)O valor veio de fora (formulário, URL, arquivo) e pode estar errado. Você trata o null.null

Um padrão muito comum junta o tryFrom com o ?? para ter um valor padrão. Aqui o status vem da linha de comando ($argv[1], veja PHP na Linha de Comando); num site viria de $_GET:

<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
}

$texto = $argv[1] ?? "";
$status = Status::tryFrom($texto) ?? Status::Pendente;
echo "Mostrando pedidos: ", $status->name, "\n";
$ php filtro.php enviado
Mostrando pedidos: Enviado
$ php filtro.php Enviado
Mostrando pedidos: Pendente     « "Enviado" não é um valor: caiu no padrão
$ php filtro.php
Mostrando pedidos: Pendente     « sem nada: também o padrão

Enum com valor int

Com : int, os valores são números. Bom para níveis, prioridades, códigos:

<?php
enum Prioridade: int
{
    case Baixa = 1;
    case Media = 2;
    case Alta = 3;
}
var_dump(Prioridade::from(3));
var_dump(Prioridade::tryFrom(7));
var_dump(Prioridade::from("2"));
var_dump(Prioridade::from(9));
$ php prioridade.php
enum(Prioridade::Alta)
NULL
enum(Prioridade::Media)        « o texto "2" foi convertido para o número 2

Fatal error: Uncaught ValueError: 9 is not a valid backing value for enum Prioridade in /home/ana/poo/prioridade.php:11
Stack trace:
#0 /home/ana/poo/prioridade.php(11): Prioridade::from(9)
#1 {main}
  thrown in /home/ana/poo/prioridade.php on line 11

Missão: as entradas vieram de um formulário, e uma delas ("perdido") está errada. Rode e veja o ValueError. Troque o from por tryFrom e, quando o resultado for null, mostre perdido: inválido em vez de parar o programa.

<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
}

$entradas = ["pago", "enviado", "perdido"];
foreach ($entradas as $texto) {
    $status = Status::from($texto);
    echo "$texto: ", $status->name, "\n";
}
<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
}

$entradas = ["pago", "enviado", "perdido"];
foreach ($entradas as $texto) {
    $status = Status::tryFrom($texto);
    if ($status === null) {
        echo "$texto: inválido\n";
    } else {
        echo "$texto: ", $status->name, "\n";
    }
}

3. Métodos dentro do enum e match

Um enum pode ter métodos, como uma classe. Dentro deles, $this é o caso atual. E a ferramenta perfeita para "fazer uma coisa diferente para cada caso" é o match. A Ana quer, para cada status do pedido, um texto para o cliente, o próximo passo e se o pedido ainda pode ser cancelado:

<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";

    public function rotulo(): string
    {
        return match ($this) {
            Status::Pendente => "Aguardando pagamento",
            Status::Pago => "Pagamento confirmado",
            Status::Enviado => "A caminho",
        };
    }

    public function proximo(): ?Status
    {
        return match ($this) {
            self::Pendente => self::Pago,
            self::Pago => self::Enviado,
            self::Enviado => null,
        };
    }

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

    public static function padrao(): self
    {
        return self::Pendente;
    }
}

$s = Status::padrao();
while ($s !== null) {
    $cancelar = $s->podeCancelar() ? "sim" : "não";
    echo "{$s->name}: {$s->rotulo()} (pode cancelar? $cancelar)\n";
    $s = $s->proximo();
}
$ php pedido-status.php
Pendente: Aguardando pagamento (pode cancelar? sim)
Pago: Pagamento confirmado (pode cancelar? não)
Enviado: A caminho (pode cancelar? não)
public function rotulo(): string
Um método comum. Chamado num caso: Status::Pago->rotulo().
match ($this) { … }
Compara o caso atual com cada opção (usando ===) e devolve o texto correspondente.
self::Pendente
Dentro do enum, self é o próprio enum: self::Pago é o mesmo que Status::Pago.
?Status
O proximo() devolve um Status ou null (depois de Enviado, não há próximo). O ? antes do tipo permite o null.
public static function padrao()
Métodos estáticos também são permitidos: Status::padrao() (veja static).
while ($s !== null)
Começa em Pendente e vai pedindo o próximo até acabar.

Por que match combina tanto com enum

O match não tem "caso nenhum, faça qualquer coisa" a menos que você escreva um default. Se aparecer um caso que ele não conhece, ele para com erro. Com enum, isso é uma rede de segurança: a Ana acrescentou o caso Cancelado e esqueceu do rotulo():

<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
    case Cancelado = "cancelado";

    public function rotulo(): string
    {
        return match ($this) {
            Status::Pendente => "Aguardando pagamento",
            Status::Pago => "Pagamento confirmado",
            Status::Enviado => "A caminho",
        };
    }
}
echo Status::Pago->rotulo(), "\n";
echo Status::Cancelado->rotulo(), "\n";
$ php cancelado.php
Pagamento confirmado

Fatal error: Uncaught UnhandledMatchError: Unhandled match case Status::Cancelado in /home/ana/poo/cancelado.php:14
Stack trace:
#0 /home/ana/poo/cancelado.php(19): Status->rotulo()
#1 {main}
  thrown in /home/ana/poo/cancelado.php on line 14

"Caso do match não tratado: Status::Cancelado". O erro diz exatamente qual caso faltou. Por isso, com enums, evite o default no match: sem ele, todo caso novo que você esquecer aparece logo no primeiro teste, em vez de cair num texto genérico. (Com switch, o caso esquecido passaria em silêncio; veja switch.)

Constantes e interfaces

Um enum também pode ter constantes (const PADRAO = self::Pendente;) e implementar interfaces (enum Status: string implements TemCor), com os métodos que a interface exige (veja Interfaces). O que ele não pode ter são propriedades: os únicos dados de um caso são name e value. Informação extra (um rótulo, uma cor) vem de métodos com match, como você viu.


Enum puro ou com valor?

Puro: enum StatusCom valor: enum Status: string
->namesimsim
->valuenãosim
cases()simsim
from() / tryFrom()nãosim
No json_encodefalha: Non-backed enums have no default serializationvira o valor
Use quandoa opção só existe dentro do programaprecisa ser gravada, enviada ou recebida (banco, JSON, formulário)

Na dúvida, se o valor um dia vai sair do programa, use enum com valor string: fica legível no banco ("pago" diz mais que 2).


Quando dá errado

Tentar criar um enum com new
$ php new-enum.php

Fatal error: Uncaught Error: Cannot instantiate enum Status in /home/ana/poo/new-enum.php:7
Stack trace:
#0 {main}
  thrown in /home/ana/poo/new-enum.php on line 7

O que significa: o programa tinha $s = new Status();. "Não é possível instanciar o enum". Os casos já existem prontos; não se cria caso novo em tempo de execução.

Como consertar: use um dos casos: $s = Status::Pendente;.

Usar o enum como texto
<?php
enum Status
{
    case Pendente;
    case Pago;
}
echo "Status: " . Status::Pago . "\n";
$ php enum-texto.php

Fatal error: Uncaught Error: Object of class Status could not be converted to string in /home/ana/poo/enum-texto.php:7
Stack trace:
#0 {main}
  thrown in /home/ana/poo/enum-texto.php on line 7

O que significa: um caso é um objeto, não um texto, e enums não podem ter __toString.

Como consertar: escolha o texto que você quer: Status::Pago->name, ->value (se tiver valor) ou um método como ->rotulo().

Valor num enum puro (ou falta de valor num enum com valor)
<?php
enum Status
{
    case Pendente = "pendente";
    case Pago = "pago";
}
$ php puro-valor.php

Fatal error: Case Pendente of non-backed enum Status must not have a value in /home/ana/poo/puro-valor.php on line 4
Stack trace:
#0 {main}

O que significa: "o caso Pendente de um enum sem valor não pode ter valor". Faltou o : string depois do nome. O contrário (enum : string com um caso sem = "…") dá Case Pago of backed enum Status must have a value.

Como consertar: ou todos os casos têm valor e o enum declara o tipo (enum Status: string), ou nenhum tem.

->value ou from() num enum puro
$ php puro-from.php

Warning: Undefined property: Status::$value in /home/ana/poo/puro-from.php on line 7

Fatal error: Uncaught Error: Call to undefined method Status::from() in /home/ana/poo/puro-from.php:8
Stack trace:
#0 {main}
  thrown in /home/ana/poo/puro-from.php on line 8

O que significa: o programa fazia echo Status::Pago->value; e Status::from("Pago") num enum puro. Ele não tem value, nem from/tryFrom.

Como consertar: se você precisa converter de/para texto, transforme-o em enum com valor.

Caso como chave de array
<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
}
$contagem = [];
$contagem[Status::Pago] = 1;
$ php chave.php

Fatal error: Uncaught TypeError: Cannot access offset of type Status on array in /home/ana/poo/chave.php:8
Stack trace:
#0 {main}
  thrown in /home/ana/poo/chave.php on line 8

O que significa: chaves de array só podem ser int ou string.

Como consertar: use o valor ou o nome como chave: $contagem[Status::Pago->value] = 1;.

Propriedades num enum

Escrever public string $cor; dentro de um enum dá Fatal error: Enum Status cannot include properties. E name/value são somente leitura: $x->value = "x"; dá Cannot modify readonly property Status::$value. Use métodos para informação extra.


Referência rápida

EscritaSignificado
enum Nome { case A; case B; }Enum puro
enum Nome: string { case A = "a"; }Enum com valor string (ou : int)
Nome::AUm caso
$caso->nameNome do caso ("A")
$caso->valueValor do caso (só com valor)
Nome::cases()Array com todos os casos, em ordem
Nome::from($v)Caso com esse valor, ou ValueError
Nome::tryFrom($v)Caso com esse valor, ou null
$a === Nome::AComparar casos
match ($this) { self::A => … }Um resultado para cada caso (sem default, de propósito)
function f(Nome $x)Aceita só casos desse enum

Missão final

Missão: a lojinha passou a aceitar cancelamentos, e o caso Cancelado já está no enum. Rode e veja o UnhandledMatchError. Conserte o match do rotulo() para que o caso Cancelado mostre Pedido cancelado (sem usar default).

<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
    case Cancelado = "cancelado";

    public function rotulo(): string
    {
        return match ($this) {
            Status::Pendente => "Aguardando pagamento",
            Status::Pago => "Pagamento confirmado",
            Status::Enviado => "A caminho",
        };
    }
}

foreach (Status::cases() as $s) {
    echo $s->name, ": ", $s->rotulo(), "\n";
}
<?php
enum Status: string
{
    case Pendente = "pendente";
    case Pago = "pago";
    case Enviado = "enviado";
    case Cancelado = "cancelado";

    public function rotulo(): string
    {
        return match ($this) {
            Status::Pendente => "Aguardando pagamento",
            Status::Pago => "Pagamento confirmado",
            Status::Enviado => "A caminho",
            Status::Cancelado => "Pedido cancelado",
        };
    }
}

foreach (Status::cases() as $s) {
    echo $s->name, ": ", $s->rotulo(), "\n";
}

Exercícios

1. Declarar

Complete a palavra que cria uma lista fechada de opções:

Naipe { case Copas; case Espadas; case Ouros; case Paus; }

2. Do banco para o enum

O banco devolveu o texto "enviado" em $linha["status"], e ele com certeza é válido. Complete para obter o caso (com erro se o valor estiver errado):

$status = Status::($linha["status"]);

3. Do enum para o banco

Complete para obter o texto "pago" a partir de Status::Pago:

$texto = Status::Pago->;

4. O que Status::tryFrom("cancelado") devolve se Status não tem nenhum caso com o valor "cancelado"?

5. Em enum Status: string { case Pago = "pago"; }, o que mostram Status::Pago->name e Status::Pago->value?

6. Por que é bom não usar default num match sobre um enum?

7. Qual destas coisas um enum não pode ter?


Resumo

  • Um enum é uma lista fechada de opções (case). Use quando um valor só pode ser um de poucos.
  • Use o enum como tipo de parâmetro: o PHP recusa qualquer outra coisa (must be of type Status, string given).
  • Compare com ===; ->name dá o nome; cases() dá a lista toda.
  • Enum com valor (: string ou : int) tem ->value, from() (erro ValueError se não achar) e tryFrom() (null se não achar).
  • from para dados confiáveis; tryFrom … ?? padrão para dados de fora.
  • Enums podem ter métodos, métodos estáticos, constantes e interfaces, mas não propriedades.
  • match ($this) sem default é a dupla perfeita: esquecer um caso vira UnhandledMatchError.