Why enums beat magic strings

For years I reached for constants or plain strings to represent states like pending, shipped, or cancelled. The problem? A typo in 'shipped' becomes a silent bug that only surfaces in production. PHP 8.1 introduced backed enums, giving you a first‑class type that the engine can verify at compile time.

Real‑world scenario: order processing

Imagine an e‑commerce service that moves an Order through a workflow. Each step — payment, packing, shipping — depends on the current status. Using strings forces you to sprinkle if ($order->status === 'shipped') everywhere, and a new status means hunting down every comparison.

Defining a backed enum

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Paid      = 'paid';
    case Packed    = 'packed';
    case Shipped   = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::Pending   => 'Awaiting payment',
            self::Paid      => 'Payment received',
            self::Packed    => 'Ready for shipment',
            self::Shipped   => 'On the way',
            self::Delivered => 'Delivered',
            self::Cancelled => 'Cancelled',
        };
    }
}

Using the enum in a service

class OrderService
{
    public function transition(Order $order, OrderStatus $next): void
    {
        $allowed = $this->allowedTransitions($order->status);
        if (!in_array($next, $allowed, true)) {
            throw new \DomainException("Invalid transition from {$order->status->value} to {$next->value}" );
        }
        $order->status = $next;
        $order->save();
    }

    private function allowedTransitions(OrderStatus $current): array
    {
        return match ($current) {
            OrderStatus::Pending   => [OrderStatus::Paid, OrderStatus::Cancelled],
            OrderStatus::Paid      => [OrderStatus::Packed, OrderStatus::Cancelled],
            OrderStatus::Packed    => [OrderStatus::Shipped],
            OrderStatus::Shipped   => [OrderStatus::Delivered],
            OrderStatus::Delivered => [],
            OrderStatus::Cancelled => [],
        };
    }
}

Benefits you’ll notice immediately

  • Type safety – the compiler rejects any value that isn’t an OrderStatus case.
  • Self‑documenting$order->status->label() gives a human‑readable string without a separate lookup table.
  • Exhaustive matching – the match expression forces you to handle every case; adding a new status produces a compile‑time error until you update the logic.
  • Database friendliness – backed enums store the scalar value ('pending', 'paid', …) directly, so migrations stay simple.

Migration tip

If you’re refactoring a legacy codebase, start by creating the enum and a thin wrapper that accepts both the old string and the new enum. Gradually replace string checks with $status instanceof OrderStatus and let static analysis (Psalm, PHPStan) surface the remaining spots.

Backed enums turn a class of runtime surprises into compile‑time guarantees — exactly the kind of safety net that lets you ship faster with confidence.