One row in orders had status 'canceled', with one l. Nobody knows how long it sat there. The enum found it on the first day.
PHP 8.1 enums are six weeks old, enough time in one project to say something practical. The first candidate was obvious: order status. For years it was a class with string constants, Order::STATUS_PAID, Order::STATUS_SHIPPED. Constants look safe, but nothing stops a function from receiving 'payed'. The parameter type was string, and string accepts everything.
enum OrderStatus: string
{
case New = 'new';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function isFinal(): bool
{
return $this === self::Shipped || $this === self::Cancelled;
}
}
Now a method takes OrderStatus, and the set of possible values is closed. A typo does not turn into a mystery, it fails at OrderStatus::from() at the boundary. That is the whole win. Not shorter code. Fewer possible states.
The boundary is the interesting part. The database still stores a string. So the cast lives in one place, in the mapping layer, and inside the domain nobody touches raw strings. The migration was boring: the column already had valid values, from() just confirmed it. Except that one row. A string constant would never have noticed.
One warning from this month. It is tempting to grow the enum into a brain. Allowed transitions, side effects, notifications. I stopped at isFinal() and a small canTransitionTo(). Anything that needs a repository or a clock does not belong in an enum. It is a value.
And do not reach for enums where the set is open. Country codes, currencies, anything that changes by configuration. Enum is for states that change only when the code changes. Status fits.
I wanted to put the transitions in there too. I still do. That is the instinct I check at the door.