enum in PHP: quando sostituiscono le costanti di classe

C’è un tipo di bug che in un progetto PHP di qualche anno si trova quasi sempre. Una classe con quattro costanti:
class Order
{
public const STATUS_DRAFT = 'draft';
public const STATUS_PAID = 'paid';
public const STATUS_SHIPPED = 'shipped';
public const STATUS_CANCELLED = 'cancelled';
private string $status = self::STATUS_DRAFT;
public function setStatus(string $status): void
{
$this->status = $status;
}
}
E da qualche parte, prima o poi, una chiamata $order->setStatus('payed'). Il codice gira, il valore finisce nel database, e il problema si scopre settimane dopo, quando un report non torna. Le costanti danno un nome ai valori, ma la firma del metodo continua a dire string: accetta qualunque stringa, comprese quelle sbagliate.
Dalla versione 8.1 PHP ha gli enum, e questo è esattamente il caso per cui esistono. Il manuale li presenta così: permettono di definire “a custom type that is limited to one of a discrete number of possible values”, e cita l’obiettivo di “making invalid states unrepresentable”. Rendere irrappresentabili gli stati non validi: è tutto lì.
Il caso base: un enum “puro”
enum OrderStatus
{
case Draft;
case Paid;
case Shipped;
case Cancelled;
}
class Order
{
private OrderStatus $status = OrderStatus::Draft;
public function setStatus(OrderStatus $status): void
{
$this->status = $status;
}
}
$order->setStatus(OrderStatus::Paid);
Ora setStatus('payed') non gira: è un errore di tipo, subito, nel punto esatto in cui lo hai scritto. E anche setStatus('paid') non gira, perché un case non è una stringa. Il manuale è esplicito: un enum puro non ha un valore scalare dietro, “each case is backed by a singleton object of that name”.
Questo ha due conseguenze pratiche:
- Il confronto si fa con
===. Ogni case è un’istanza unica, quindiOrderStatus::Paid === OrderStatus::Paidè vero, e funziona ancheinstanceof OrderStatus. <e>non hanno senso. Il manuale lo dice chiaramente: quei confronti “will always returnfalsewhen working with enum values”. Se ti serve un ordinamento tra stati, va scritto in un metodo, non affidato agli operatori.
Ogni case ha una proprietà in sola lettura, name, che è “the case-sensitive name of the case itself”: OrderStatus::Paid->name restituisce "Paid".
Quando serve un valore nel database: i backed enum
Un enum puro va benissimo dentro il codice, ma un oggetto non si salva in una colonna. Per questo esistono i backed enum, in cui ogni case ha un valore scalare:
enum OrderStatus: string
{
case Draft = 'draft';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
echo OrderStatus::Paid->value; // "paid"
Le regole, dal manuale:
- il tipo può essere
intostring, uno solo per enum (“no union ofint|string“); - se l’enum è backed, tutti i case devono avere un valore, esplicito e unico: “There are no auto-generated scalar equivalents (e.g., sequential integers)”. Niente numerazione automatica come in altri linguaggi;
- un enum backed contiene solo case backed, uno puro solo case puri.
Il punto in cui i backed enum ripagano è la lettura dal database o da una richiesta HTTP, con due metodi statici già pronti:
$status = OrderStatus::from($row['status']); // ValueError se il valore non esiste
$status = OrderStatus::tryFrom($input) ?? OrderStatus::Draft; // null se non esiste
from() lancia un ValueError se il valore non corrisponde a nessun case; tryFrom() restituisce null. La scelta tra i due non è di gusto: from() per i dati che devono essere validi (una colonna del tuo database: se c’è un valore sconosciuto, vuoi saperlo subito), tryFrom() per i dati che arrivano da fuori (un parametro di una querystring), dove un valore sbagliato è normale e va gestito.
Metodi e interfacce
Qui gli enum smettono di essere “costanti con il tipo giusto” e diventano utili davvero. Un enum può avere metodi, e dentro un metodo $this “refers to the Case instance”:
enum OrderStatus: string
{
case Draft = 'draft';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
OrderStatus::Draft => 'Bozza',
OrderStatus::Paid => 'Pagato',
OrderStatus::Shipped => 'Spedito',
OrderStatus::Cancelled => 'Annullato',
};
}
public function canBeCancelled(): bool
{
return match ($this) {
OrderStatus::Draft, OrderStatus::Paid => true,
OrderStatus::Shipped, OrderStatus::Cancelled => false,
};
}
}
La logica che prima era sparsa in if ($order->status === 'paid' || ...) in dieci file diversi vive adesso in un posto solo, accanto ai valori a cui si riferisce.
match è il compagno naturale degli enum per due motivi. Confronta con ===, non con == come switch. E deve essere esaustivo: “If the subject expression is not handled by any match arm, an UnhandledMatchError is thrown”. Quindi, se domani aggiungi case Refunded e dimentichi di aggiornare label(), non ottieni un’etichetta vuota in silenzio: ottieni un errore. Attenzione però, l’errore arriva a runtime, quando quel ramo viene eseguito, non alla compilazione. Aggiungere un case vuol dire cercare tutti i match su quell’enum.
Un enum può anche implementare interfacce, e in quel caso “any type check for that interface will also accept all cases of that Enum”. Su un backed enum la dichiarazione va dopo il tipo: enum OrderStatus: string implements HasLabel.
Può avere anche costanti, comprese quelle che puntano a un case, utili come alias:
enum OrderStatus: string
{
// ...
public const Default = self::Draft;
}
Infine, OrderStatus::cases() restituisce tutti i case “in the order of declaration”: è quello che serve per costruire una <select> senza riscrivere la lista a mano.
Cosa un enum non può avere
Il manuale lo riassume in una frase: “Enum cases are forbidden from having state”. In pratica:
- niente costruttori né distruttori;
- niente proprietà, né statiche né d’istanza;
- niente ereditarietà: un enum non estende e non può essere esteso;
- niente
new OrderStatus()(dà “Cannot instantiate enum”), e nienteclone; - dei metodi magici sono ammessi solo
__call,__callStatice__invoke.
Se ti trovi a voler mettere uno stato in un enum (un contatore, una data, un riferimento a un utente), è il segnale che ti serve una classe, non un enum. L’enum descrive quale tra pochi valori fissi; i dati che cambiano stanno altrove.
Errori da evitare
- Definire
from(),tryFrom()ocases()a mano. Sono già forniti, e ridefinirli è un errore fatale. - Confrontare un case con una stringa.
$status === 'paid'è sempre falso se$statusè un enum: confronta conOrderStatus::Paid, oppure con$status->valuese proprio stai lavorando con il valore grezzo. - Usare
from()su input esterno senza gestire l’eccezione. Un parametro manomesso nella querystring diventa un errore 500. Su quel tipo di dato si usatryFrom(). - Trasformare in enum ogni insieme di costanti. Se i valori cambiano spesso, arrivano da una tabella configurabile o li gestisce un utente dal pannello, non sono un enum: sono dati.
Quando conviene
La regola che uso è semplice: se una funzione accetta una string o un int ma in realtà solo tre o quattro valori sono validi, quel parametro vuole essere un enum. Il costo è un file in più; il guadagno è che il valore sbagliato non arriva più nel database, perché non arriva nemmeno alla chiamata.
Se hai un’applicazione PHP che si porta dietro anni di costanti e stringhe magiche e vuoi capire da dove conviene iniziare a sistemarla, scrivici.
