The trouble with magic strings
An order has a status: pending, paid, shipped, delivered or cancelled. A natural first approach stores the status as a string:
<?php
$order['status'] = 'shipped';
if ($order['status'] === 'shiped') { /* never true: a typo */ }
function markAs(string $status) { /* accepts 'banana' just as happily */ }Nothing stops a typo, and every function that takes a status has to check it is one of the allowed values. Class constants from Static members and class constants help a little, but the parameter type is still just string.
PHP 8.1 added enumerations (enums): a type with a fixed list of possible values.
A pure enum
An enum lists its cases. Each case is a value of the enum's type, and nothing else is:
<?php
enum Size
{
case Small;
case Medium;
case Large;
}
function price(Size $size): int
{
return match ($size) {
Size::Small => 99,
Size::Medium => 149,
Size::Large => 199,
};
}
echo price(Size::Medium) . "\n";
echo Size::Large->name . "\n";
var_dump(Size::Small === Size::Small);
var_dump(Size::Small instanceof Size);149
Large
bool(true)
bool(true)price() accepts only a Size. Passing the string 'medium' would be a TypeError. Every case has a name property, and cases can be compared with === because each case exists exactly once. match and enums are a natural pair, as switch and match hinted: if a new case is added later and a match does not handle it, PHP throws an UnhandledMatchError that you notice immediately.
Backed enums
To store a status in a database or send it in JSON, you need a plain value. A backed enum gives every case a string or int value:
<?php
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
}
$status = OrderStatus::Shipped;
echo $status->name . " is stored as '" . $status->value . "'\n";
$fromDatabase = OrderStatus::from('paid');
var_dump($fromDatabase === OrderStatus::Paid);
var_dump(OrderStatus::tryFrom('lost'));
echo count(OrderStatus::cases()) . " statuses\n";
echo json_encode(['status' => OrderStatus::Delivered]) . "\n";Shipped is stored as 'shipped'
bool(true)
NULL
5 statuses
{"status":"delivered"}->valueis the backing value you save.from()turns a stored value back into a case and throws aValueErrorfor an unknown value;tryFrom()returnsnullinstead, which is the right choice for user input.cases()lists every case, handy for building a<select>menu.json_encode()writes the backing value automatically.
In MySQL you would store the value in a VARCHAR or ENUM column; Laravel can cast a model attribute to an enum for you.
Methods on enums
Enums can have methods, which keeps everything about a status in one place instead of scattered if statements:
<?php
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Waiting for payment',
self::Paid => 'Preparing your order',
self::Shipped => 'On its way',
self::Delivered => 'Delivered',
self::Cancelled => 'Cancelled',
};
}
public function canCancel(): bool
{
return in_array($this, [self::Pending, self::Paid], true);
}
/** @return list<self> */
public static function open(): array
{
return array_values(array_filter(self::cases(), fn(self $s) => !in_array($s, [self::Delivered, self::Cancelled], true)));
}
}
foreach (OrderStatus::cases() as $status) {
printf("%-10s %-22s cancel: %s\n", $status->value, $status->label(), $status->canCancel() ? 'yes' : 'no');
}
echo "Open: " . implode(', ', array_map(fn($s) => $s->name, OrderStatus::open())) . "\n";pending Waiting for payment cancel: yes
paid Preparing your order cancel: yes
shipped On its way cancel: no
delivered Delivered cancel: no
cancelled Cancelled cancel: no
Open: Pending, Paid, ShippedInside the enum, $this is the current case and self:: refers to the enum. Enums can also define constants and implement interfaces, but they cannot have properties that change: each case is fixed.
Enums with form input
Combine tryFrom() with the validation habits from Validating and escaping form input:
<?php
enum OrderStatus: string
{
case Pending = 'pending';
case Shipped = 'shipped';
}
foreach (['shipped', 'SHIPPED', 'teleported'] as $input) {
$status = OrderStatus::tryFrom(strtolower(trim($input)));
echo $input . " => " . ($status?->name ?? 'invalid status') . "\n";
}shipped => Shipped
SHIPPED => Shipped
teleported => invalid statusThe nullsafe operator ?-> reads name only when a case was found.
Common mistakes
- Using from() on user input, which throws for any unexpected value. Use
tryFrom()and check fornull. - Comparing a case with its string value:
OrderStatus::Paid === 'paid'is false. Compare->value, or convert the string first. - Leaving out cases in a match without a default, then adding a case later. That is a feature: the error shows you where to update.
- Using enums for lists that change often, such as product categories that editors create. Those belong in a database table.
Exercise
Create a backed enum Plan: string with cases Free = 'free', Pro = 'pro' and Team = 'team', and methods monthlyPrice(): int (0, 499, 1999) and maxProjects(): ?int (3, 50, unlimited as null). Print a line for every plan like pro: 499/month, 50 projects.
Show one possible answer
<?php
enum Plan: string
{
case Free = 'free';
case Pro = 'pro';
case Team = 'team';
public function monthlyPrice(): int
{
return match ($this) {
self::Free => 0,
self::Pro => 499,
self::Team => 1999,
};
}
public function maxProjects(): ?int
{
return match ($this) {
self::Free => 3,
self::Pro => 50,
self::Team => null,
};
}
}
foreach (Plan::cases() as $plan) {
$projects = $plan->maxProjects() ?? 'unlimited';
echo "{$plan->value}: {$plan->monthlyPrice()}/month, $projects projects\n";
}free: 0/month, 3 projects
pro: 499/month, 50 projects
team: 1999/month, unlimited projectsKey points
- An enum defines a type with a fixed list of possible values, called cases: enum Status { case Pending; case Paid; }.
- A backed enum gives each case a string or int value for storage: case Paid = 'paid';
- Status::from('paid') converts a stored value back to a case; tryFrom() returns null instead of throwing for unknown values.
- Enums can have methods and constants and implement interfaces; match over an enum keeps every case handled.