A single string took down a payment flow I built. Not a malicious string, nothing clever. Just “canceled” where the rest of the system expected “cancelled.” One missing l. The American spelling slipped in through a form field, sailed past every check I had, and settled into the database as a status that no switch statement in the codebase knew how to handle. Orders in that state simply stopped moving. No error, no alert. They just sat there until a customer emailed to ask where their refund was.
I had written what I thought were the right defenses. A class full of status constants. A validation helper. A code comment that politely asked everyone to use the constants. None of it mattered, because a class constant in PHP is still just a string the moment you pass it into a function, and PHP was perfectly content to accept any other string in its place.
PHP enums fixed this entire category of bug for me. They shipped about four years ago, in PHP 8.1, and I was slow to trust them fully. This is the post I wish someone had pushed in front of me back in 2022.
The class-constant pattern I leaned on for years
For a long time my version of an “enum” in PHP was a class stuffed with constants. It looked responsible. It was not.
class OrderStatus
{
public const PENDING = 'pending';
public const PAID = 'paid';
public const SHIPPED = 'shipped';
public const CANCELLED = 'cancelled';
public const ALL = [
self::PENDING,
self::PAID,
self::SHIPPED,
self::CANCELLED,
];
public static function isValid(string $status): bool
{
return in_array($status, self::ALL, true);
}
}
Then everywhere a status was accepted, I had to remember to call the guard by hand:
function updateStatus(string $status): void
{
if (!OrderStatus::isValid($status)) {
throw new InvalidArgumentException("Bad status: {$status}");
}
// ... actual work
}
Look at the signature. It still says string. The type system still knows nothing useful about what is allowed. Every single function that accepted a status had to re-run that validation, or trust that somebody upstream already had. Miss one entry point, a form handler, a queue job, a webhook, and the bad value walks straight in unchallenged. The “cancelled” bug got in through a path that, it turned out, had never called isValid at all. The constant class gave me the comforting feeling of safety without much of the actual safety.
What a backed enum replaces
A backed enum collapses the class, the constant array, and the validation helper into a single type.
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
Now the function signature does the job the guard used to do, and it does it for free:
function updateStatus(OrderStatus $status): void
{
// $status is one of exactly four values. Nothing to check.
}
You cannot pass 'cancelled' to this function. You cannot pass 'canceled' either, which is the whole point of the exercise. You pass OrderStatus::Cancelled or your code does not run. When a value arrives as a raw string from outside, a database row or a JSON payload, you convert it once, at the edge:
$status = OrderStatus::from($row['status']); // throws on garbage
$maybe = OrderStatus::tryFrom($row['status']); // returns null instead
from() throws a ValueError when it sees an unknown string. tryFrom() hands you null so you can decide what to do next. Either way you validate once, at the boundary, instead of scattering guard calls through every function that touches a status. And when you need the full list, the one the old ALL constant existed to provide, every enum has a static cases() method that returns all cases in declaration order. Building a dropdown or seeding a test becomes a one-liner that can never drift out of sync with the real cases. The enumerations RFC and the PHP manual document the full behavior, and both are worth ten quiet minutes.
Methods on enums are the part I underused
Here is what I missed for an embarrassingly long time: enums can hold methods. For my first year with them I kept the enum as a dumb list of cases and parked all the behavior in separate helper classes. Old habits die slowly.
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Paid, not yet shipped',
self::Shipped => 'On its way',
self::Cancelled => 'Cancelled',
};
}
public function isTerminal(): bool
{
return match ($this) {
self::Shipped, self::Cancelled => true,
default => false,
};
}
}
Two built-ins do most of the day-to-day work. Every case exposes ->name, the case name as a string, and a backed enum also exposes ->value, the backing value. When you serialize to JSON, a backed enum encodes as its value with no help from you, so json_encode(['status' => OrderStatus::Paid]) gives you {"status":"paid"} directly. That alone deleted a pile of hand-written array glue from my API layer.
The match expressions inside those methods carry a quieter benefit. A match with no default arm is exhaustive: add a fifth case to the enum and the first time the code hits that new value through an un-updated match, PHP throws an \UnhandledMatchError. That converts “I added a status and forgot to update the display logic” from a silent gap in the UI into a loud failure at the exact line that needs attention. I will take a loud failure over a quiet one every time. The quiet ones are the expensive ones.
Where enums and interfaces get genuinely nice
Enums can implement interfaces. That sounds like a checkbox feature until you have two different enums that need to behave the same way.
interface HasLabel
{
public function label(): string;
}
enum OrderStatus: string implements HasLabel
{
case Pending = 'pending';
case Paid = 'paid';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Paid',
};
}
}
Now anything that renders a label, a Blade component, a serializer, a dropdown builder, can type-hint HasLabel and accept any enum that satisfies it. Pure enums, the kind with no backing value, work here too, and they have a use I badly underrated: states that should never be serialized in the first place. A Direction enum with North, South, East, and West does not need string values if it never goes near a database. Giving it backing values just invites someone to persist one. I cover a related typing habit in my write-up on PHP 8.4 property hooks, since the two features tend to show up together once you start building real value objects.
The limit that still bites
Enums are not classes you can do anything you like with, and that surprises people who assume otherwise. The big one: enum cases are singletons, and they cannot carry per-instance state. This does not compile:
enum OrderStatus: string
{
case Pending = 'pending';
public ?DateTimeImmutable $changedAt = null; // Fatal error
}
Enums cannot have mutable properties. Every reference to OrderStatus::Pending anywhere in the running process is the exact same object. That is correct behavior for what an enum is meant to be, but if you catch yourself wanting to attach data to a case, you have outgrown the tool. What you actually want is a class, probably a readonly value object that holds an enum plus whatever extra data, like that timestamp, belongs with it.
There is no enum inheritance either. You cannot extend one enum from another, and there is no such thing as an abstract enum. Interfaces are the only sharing mechanism you get, which is usually enough once you stop fighting it. Enums are allowed to declare their own constants, but the moment you reach for self::SOMETHING inside an enum to fake a bit of state, stop and reconsider. That is the signal you have crossed a line. I hit this exact wall building a small workflow engine, and the refactor out of it taught me more than the enum did. Some of that work is in my portfolio.
Where to start this week
If your codebase still has a class of string constants with an isValid() helper sitting next to it, that is the single highest-value thing you can convert this week. Create the enum. Change the function signatures from string to the enum type. Then let the type errors walk you to every place that was quietly passing raw strings around.
The compiler turns into a checklist you did not have to write. You will almost certainly find one or two call sites that were never validated at all. Mine was a webhook handler that nobody had looked at in a year. That is the bug you do not know you have, and once the enum is in place, the spelling of “cancelled” will never cost you a payment flow again.