Before PHP 8.1, every codebase I worked in had its own version of the same workaround: a class full of public class constants standing in for an enum, plus a comment somewhere warning "don't pass anything that isn't one of these." Native enums close that gap. They're not just syntax sugar over class constants — they're a real type, they can implement interfaces, they can carry methods, and the engine will reject an invalid value at the type-check boundary instead of letting a stray string slip through into a switch statement with no default case.
Pure enums vs backed enums
A pure enum is just a fixed set of named cases with no underlying scalar value — useful when the only thing you care about is which case you have, not what it serializes to. A backed enum attaches an int or string value to every case, which is what you want the moment the value needs to cross a boundary: stored in a database column, returned in a JSON API response, or read from a config file.
// Pure enum — no backing value, compared by identity only
enum Direction
{
case North;
case South;
case East;
case West;
}
// Backed enum — every case maps to a scalar you control
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
$status = OrderStatus::from('paid'); // OrderStatus::Paid, throws ValueError if no match
$maybe = OrderStatus::tryFrom('unknown'); // null instead of throwing
echo $status->value; // 'paid'
Backing values must all be the same type — you can't mix int and string cases in one enum — and every case needs a unique value. from() throws a ValueError on no match; tryFrom() gives you null instead, which is the one to reach for at the edge of your app where the input is untrusted, like a query parameter or a webhook payload.
Methods and interfaces on enums
This is the part that actually changes how you write code, not just how you validate it. Enums can define methods, including ones that switch on $this, and they can implement interfaces — which means an enum can be a legitimate, type-hintable abstraction rather than a bag of constants you look up from elsewhere.
interface HasColor
{
public function color(): string;
}
enum OrderStatus: string implements HasColor
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function color(): string
{
return match ($this) {
self::Pending => 'gray',
self::Paid => 'green',
self::Shipped => 'blue',
self::Cancelled => 'red',
};
}
public function isTerminal(): bool
{
return $this === self::Shipped || $this === self::Cancelled;
}
}
// Anywhere in the app, $status is guaranteed to be one of four values
if (!$status->isTerminal()) {
// safe to transition
}
Compare that to the old approach: a StatusHelper::color(string $status) static method living in some unrelated class, taking a raw string with no compile-time guarantee it's one of the values you expect. The logic that "belongs" to a status now lives on the status itself, and PHPStan or Psalm can tell you at analysis time if you've missed a case in a match — because match expressions over enums without a default arm throw UnhandledMatchError at runtime if a case isn't covered, and static analysis tools flag that gap before you ship.
You can't add arbitrary instance properties to an enum case — each case is a singleton instance and the engine doesn't let you bolt extra state onto it. You can add const declarations and static methods, though, which covers most of what you'd otherwise reach for a property to do.
What this replaces
The pre-8.1 simulation usually looked like a final class with a handful of public constants, an array map for "valid values," and a runtime check (in_array($value, self::ALL, true)) sprinkled wherever the value entered the system. It worked, but every piece of behavior tied to the constant — display labels, color codes, whether a status was terminal — lived somewhere else, often duplicated across a model, a form request, and a frontend TypeScript file that had to be kept in sync by hand. None of that validation was enforced by the type system; a typo'd string just failed silently or threw far from where the mistake was made.
Enums as type hints, not just values
Once a value is backed by an enum, you can type-hint parameters and return types with it directly, which is where most of the real benefit shows up in day-to-day code — not in the enum definition itself, but in every function signature that no longer accepts a bare string.
function transitionOrder(Order $order, OrderStatus $to): void
{
if ($order->status->isTerminal()) {
throw new LogicException('Cannot transition a terminal order.');
}
$order->status = $to;
$order->save();
}
// Callers must pass an OrderStatus case — a raw string is a type error,
// caught before the function body ever runs.
transitionOrder($order, OrderStatus::Shipped);
A native enum is a PHP-level construct, not a database type. If you're on Eloquent, casting a column to a backed enum (protected $casts = ['status' => OrderStatus::class];) handles the conversion on read/write, but the underlying column is still a plain string or int column — migrations, indexes, and raw queries all still deal in the scalar value, not the enum.
Wrapping up
PHP 8.1's enums aren't a big feature in terms of surface area, but they fix a specific and common source of bugs: values that were conceptually closed sets but were represented as open-ended strings or ints with validation scattered across the codebase. Backed enums give you a real type at the boundary, methods and interfaces let you attach behavior directly to the cases instead of a separate helper class, and static analysis can now catch the unhandled-case bugs that used to only surface at runtime. If you're still passing raw strings around for anything with a fixed set of valid values, that's the first place to introduce one.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.