+39 3662317539/+34 620899163
info@simonecosci.com

enum in PHP: quando sostituiscono le costanti di classe

Sviluppo Siti Web a Tenerife

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, quindi OrderStatus::Paid === OrderStatus::Paid è vero, e funziona anche instanceof OrderStatus.
  • < e > non hanno senso. Il manuale lo dice chiaramente: quei confronti “will always return false when 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 int o string, uno solo per enum (“no union of int|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 niente clone;
  • dei metodi magici sono ammessi solo __call, __callStatic e __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() o cases() 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 con OrderStatus::Paid, oppure con $status->value se 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 usa tryFrom().
  • 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.