A customer writes in: their order has been "processing" for nine days. You dig in and find a queue job that sets status = 'canceled' while every other part of the app checks for 'cancelled'. The refund listener never fired, the dashboard filter never matched, and nobody noticed because both strings are perfectly valid PHP. A grep finds the status strings in 40-odd files.
Native enums landed in PHP 8.1 and they fix exactly this class of bug. Most teams adopt them halfway, though: they declare case Paid = 'paid' and keep the business rules in if statements spread around the codebase. This tip goes further: backed enums as the single source of truth, behaviour living on the enum, interfaces and constants, safe parsing with tryFrom(), Eloquent casts, and a migration path off magic strings that does not break production data.
Pure Enums vs Backed Enums
An enum is a closed set of named values. PHP gives you two flavours:
enum Suit { case Hearts; case Spades; } // pure: no scalar value
// backed: every case maps to a unique int or string you can persist
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
}
echo OrderStatus::Paid->name; // "Paid" (the identifier in code)
echo OrderStatus::Paid->value; // "paid" (what goes into the database)
var_dump(OrderStatus::Paid === OrderStatus::from('paid')); // bool(true): cases are singletons
A pure enum has cases and nothing else. Use it for states that never leave the process: a parser mode, a UI variant, a strategy selector. A backed enum gives each case a scalar (int or string, nothing else) that must be unique and constant. That scalar is what you store in the database, put in JSON, and receive from forms.
A few facts worth knowing before you build on them:
->nameis the identifier you wrote in code.->valueis the persisted value. Renaming a case is a refactor; changing a value is a data migration.- Every case is a singleton object, so
===works and is the comparison you want. - Backed enums implement
BackedEnum(which extendsUnitEnum); pure enums implement onlyUnitEnum. You can type-hint against those interfaces in generic code. json_encode()turns a backed case into its value. A pure case cannot be serialised to JSON at all: withJSON_THROW_ON_ERRORyou get aJsonExceptionsaying non-backed enums have no default serialization.
Prefer string backing: status = 3 in a support query tells nobody anything.
Put Behaviour on the Enum: Methods and match
The real payoff comes when the rules that belong to a status live next to it. Enums can have methods (instance and static), and match ($this) makes them compact:
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 => 'Paid',
self::Shipped => 'On the way',
self::Delivered => 'Delivered',
self::Cancelled => 'Cancelled',
};
}
public function isFinal(): bool
{
return $this === self::Delivered || $this === self::Cancelled;
}
/** @return list<self> */
public function allowedTransitions(): array
{
return match ($this) {
self::Pending => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped => [self::Delivered],
self::Delivered, self::Cancelled => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions(), true);
}
}
Two details make this robust. First, there is no default arm in the match expressions. If someone adds case Refunded = 'refunded' and forgets to handle it, PHP throws UnhandledMatchError the first time that path runs, and a test that loops over cases() catches it before production. A default arm would silently give refunded orders the wrong label.
Second, the transition table now has exactly one home. Controllers ask $order->status->canTransitionTo(OrderStatus::Shipped) instead of re-implementing in_array($order->status, ['paid']). When product changes the rules, you change one method and the tests that cover it.
What enums cannot do: hold state. An enum may not declare properties, not even static ones, and cases cannot be constructed with new.
Constants, Static Methods and Interfaces
Enums can implement interfaces, use traits (as long as the trait declares no properties), declare constants, and have static methods. That lets them plug into generic code:
interface HasLabel
{
public function label(): string;
}
trait ListsValues
{
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
enum PaymentMethod: string implements HasLabel
{
use ListsValues;
case Card = 'card';
case BankTransfer = 'bank_transfer';
case Wallet = 'wallet';
public const DEFAULT = self::Card;
public const DEFAULT_VALUE = self::Card->value; // allowed since PHP 8.2
public function label(): string
{
return match ($this) {
self::Card => 'Credit / debit card',
self::BankTransfer => 'Bank transfer',
self::Wallet => 'E-wallet',
};
}
}
echo implode(',', PaymentMethod::values()); // card,bank_transfer,wallet
HasLabel lets a form component render any labelled enum. The ListsValues trait removes a line you would otherwise copy into every enum. Constants are handy for defaults: PaymentMethod::DEFAULT is a real case, so it is type-safe everywhere it is used. Since PHP 8.2 you can also read ->value inside a constant expression, which is useful for attribute arguments and migration defaults.
from() vs tryFrom(): Parsing Untrusted Input
Backed enums come with two constructors for turning a scalar back into a case:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
// from(): use when an invalid value is a bug. Throws ValueError.
$status = OrderStatus::from('paid');
// tryFrom(): use at the boundary, where bad input is expected. Returns null.
function statusFromWebhook(array $payload): OrderStatus
{
$raw = $payload['status'] ?? null;
if (! is_string($raw)) {
throw new InvalidArgumentException('Webhook payload has no status.');
}
// Normalise known aliases first.
$normalised = match (strtolower(trim($raw))) {
'canceled' => 'cancelled',
default => strtolower(trim($raw)),
};
return OrderStatus::tryFrom($normalised)
?? throw new InvalidArgumentException("Unknown order status [{$raw}].");
}
echo statusFromWebhook(['status' => ' Canceled '])->name; // Cancelled
The rule of thumb:
from()when an invalid value means a bug in your own system: a value you wrote yourself, a config key, a column you control. It throwsValueError, and you want it loud.tryFrom()at the edges: webhooks, CSV imports, query strings, anything a third party sends. It returnsnull, and the?? throwidiom turns that into a domain-specific exception with a useful message.
Watch the types under declare(strict_types=1): an int-backed enum's from('1') throws a TypeError, not a ValueError, because the argument must be an int. Cast at the boundary ((int) $request->input('level')) after validating that it is numeric.
Eloquent Enum Casts in Laravel 13
Laravel casts a column straight to a backed enum. Reading gives you a case, writing accepts a case (or its value), and the query builder binds enum cases as their scalar value:
namespace App\Models;
use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Model;
final class Order extends Model
{
protected $fillable = ['status', 'payment_method', 'allowed_methods'];
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'payment_method' => PaymentMethod::class,
'allowed_methods' => AsEnumCollection::of(PaymentMethod::class),
];
}
public function markAsPaid(): void
{
if (! $this->status->canTransitionTo(OrderStatus::Paid)) {
throw new \DomainException("Cannot pay an order that is {$this->status->value}.");
}
$this->status = OrderStatus::Paid;
$this->save();
}
public function scopeOpen(Builder $query): void
{
// The query builder binds BackedEnum cases as their ->value.
$query->whereIn('status', [OrderStatus::Pending, OrderStatus::Paid, OrderStatus::Shipped]);
}
}
AsEnumCollection::of() stores a JSON array of values and hydrates a collection of cases, which fits "allowed methods per merchant" style columns. For input, Rule::enum() validates against the cases, only() / except() narrow it, and $request->enum('status', OrderStatus::class) returns the case (or null):
namespace App\Http\Requests;
use App\Enums\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class UpdateOrderStatusRequest extends FormRequest
{
public function rules(): array
{
return [
'status' => ['required', Rule::enum(OrderStatus::class)->only([OrderStatus::Shipped, OrderStatus::Cancelled])],
];
}}
Route binding understands backed enums too: type-hint OrderStatus $status and /orders/status/bogus returns a 404 with no code from you.
For the column itself, keep a plain string (say varchar(32)) with ->default(OrderStatus::Pending->value) rather than a database ENUM type. Adding a case then needs no schema change, and the PHP enum stays the single source of truth.
Replacing Magic Strings Without Breaking Production
Migrating is a sequence, not a big bang, because the cast throws on the first value it cannot map:
- Grep the strings, including views, JavaScript and SQL reports. This is your checklist.
- Audit the database. Before writing the cast, find out what is actually stored. Legacy rows always contain surprises:
-- Run this BEFORE adding the cast. Every row must map to a case.
SELECT status, COUNT(*) AS total
FROM orders
GROUP BY status
ORDER BY total DESC;
- Create the enum with the values the database really contains after cleanup.
- Add the cast. Every
$order->status === 'paid'comparison is now silentlyfalse; fix them in the same pull request using the grep list. - Validate input with
Rule::enum()so new bad values cannot get in. - Move logic into the enum: transitions, labels, colours, "is this final".
- Delete the old constants (
Order::STATUS_PAIDand friends) once nothing references them.
Steps 4 and 6 can ship in separate deploys; the audit must happen before the cast goes live.
Testing Enums with Pest
Enum logic is pure, so tests are fast and need no setup. Test the transition table with a dataset, loop over cases() so new cases cannot slip through untested, and add one database-level guard:
use App\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;
it('allows only the documented transitions', function (OrderStatus $from, OrderStatus $to, bool $allowed) {
expect($from->canTransitionTo($to))->toBe($allowed);
})->with([
'pay a pending order' => [OrderStatus::Pending, OrderStatus::Paid, true],
'ship a paid order' => [OrderStatus::Paid, OrderStatus::Shipped, true],
'ship an unpaid order' => [OrderStatus::Pending, OrderStatus::Shipped, false],
'revive a cancelled order' => [OrderStatus::Cancelled, OrderStatus::Pending, false],
]);
it('gives every case a label', function () {
foreach (OrderStatus::cases() as $case) {
expect($case->label())->toBeString()->not->toBeEmpty();
}
});
it('has no status values in the database that the enum cannot read', function () {
$unknown = DB::table('orders')
->distinct()
->pluck('status')
->reject(fn (string $value): bool => OrderStatus::tryFrom($value) !== null);
expect($unknown)->toBeEmpty();
});
Run the last test against a staging snapshot and it tells you whether the cast will throw on real data. The cases() loop catches a new case without a label.
Common Pitfalls
Comparing a cast attribute to a string. After adding the cast, $order->status === 'paid' is always false because you are comparing an object to a string. No error, just wrong behaviour. Compare to the case: $order->status === OrderStatus::Paid.
Using cases as array keys. Enums are objects, so [Plan::Free => 10] throws a TypeError. Use $limits[Plan::Pro->value], or a WeakMap when you really need the case itself as the key.
A default arm in match. It defeats the exhaustiveness check that makes enums safer than strings. Leave it out unless "everything else" is a real business rule.
Invalid rows crash reads. If the database contains a value the enum does not know, reading the attribute throws ValueError. That happens on page loads and in queue workers, not just in the code that wrote the bad value. Clean data first, then cast.
Changing a case value casually. The value lives in rows, cache entries, queued jobs and API clients. Treat it like a column rename: add the new value, migrate data, remove the old one.
When to Use Enums and When Not To
Use a backed enum when the set of values is closed and owned by your code: order states, roles with hard-coded permissions, payment methods you actually integrate with, log levels, feature tiers. Adding a case is a code change that goes through review, which is exactly what you want for values that carry logic.
Use a pure enum for in-process choices that are never stored, like sort directions.
Do not use an enum when the list is data rather than code. Product categories that admins create, per-tenant ticket statuses, countries with editable metadata: those belong in a table with a foreign key. If adding a value should not require a deploy, it is not an enum.
The goal is simple: every place that cares about a status should ask the enum, and the database should only ever contain values the enum can read.
Try the challenge
Practice what you just learned. Write your solution, reveal hints if you get stuck.
Instructions
Implement an order state machine with a backed enum. OrderStatus (PHP: string-backed enum; Python: StrEnum) has the cases Pending/PENDING='pending', Paid/PAID='paid', Shipped/SHIPPED='shipped', Delivered/DELIVERED='delivered', Cancelled/CANCELLED='cancelled'. isFinal() / is_final() returns true only for Delivered and Cancelled. canTransitionTo(next) / can_transition_to(nxt) allows exactly: Pending to Paid or Cancelled, Paid to Shipped or Cancelled, Shipped to Delivered; everything else (including a status to itself and anything out of a final status) returns false. The static parse(raw) / classmethod parse(raw) trims whitespace, lowercases, maps the alias 'canceled' to 'cancelled' and returns the matching case; unknown values throw InvalidArgumentException (PHP) or raise ValueError (Python). The function transition(current, next) parses the next value, throws DomainException (PHP) or raises InvalidTransition (Python) when the move is not allowed, and returns the new status otherwise.
Starter code
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function isFinal(): bool
{
// TODO: Delivered and Cancelled are final, everything else is not
throw new LogicException('TODO');
}
public function canTransitionTo(self $next): bool
{
// TODO: Pending -> Paid|Cancelled, Paid -> Shipped|Cancelled, Shipped -> Delivered
// TODO: final states allow nothing; a status never transitions to itself
throw new LogicException('TODO');
}
public static function parse(string $raw): self
{
// TODO: trim + lowercase, map the alias 'canceled' to 'cancelled',
// then use tryFrom(); throw InvalidArgumentException for unknown values
throw new LogicException('TODO');
}
}
function transition(OrderStatus $current, string $next): OrderStatus
{
// TODO: parse $next, throw DomainException if the move is not allowed,
// otherwise return the new status
throw new LogicException('TODO');
}
from enum import StrEnum
class InvalidTransition(Exception):
pass
class OrderStatus(StrEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
def is_final(self) -> bool:
# TODO: DELIVERED and CANCELLED are final, everything else is not
raise NotImplementedError
def can_transition_to(self, nxt: "OrderStatus") -> bool:
# TODO: PENDING -> PAID|CANCELLED, PAID -> SHIPPED|CANCELLED, SHIPPED -> DELIVERED
# TODO: final states allow nothing; a status never transitions to itself
raise NotImplementedError
@classmethod
def parse(cls, raw: str) -> "OrderStatus":
# TODO: strip + lowercase, map the alias "canceled" to "cancelled",
# raise ValueError for unknown values
raise NotImplementedError
def transition(current: OrderStatus, nxt: str) -> OrderStatus:
# TODO: parse nxt, raise InvalidTransition if the move is not allowed,
# otherwise return the new status
raise NotImplementedError
Your solution
Hints
All hints revealedPut the transition table in one match ($this) expression (or a dict in Python) with no default arm, so a new case cannot be forgotten
Normalise first (trim, lowercase, alias), then call tryFrom() and turn null into an exception with ?? throw
transition() should reuse parse() and canTransitionTo() instead of duplicating any rule
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function isFinal(): bool
{
return $this === self::Delivered || $this === self::Cancelled;
}
public function canTransitionTo(self $next): bool
{
$allowed = match ($this) {
self::Pending => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped => [self::Delivered],
self::Delivered, self::Cancelled => [],
};
return in_array($next, $allowed, true);
}
public static function parse(string $raw): self
{
$value = strtolower(trim($raw));
if ($value === 'canceled') {
$value = 'cancelled';
}
return self::tryFrom($value)
?? throw new InvalidArgumentException("Unknown order status [{$raw}].");
}
}
function transition(OrderStatus $current, string $next): OrderStatus
{
$target = OrderStatus::parse($next);
if (! $current->canTransitionTo($target)) {
throw new DomainException("Cannot move from {$current->value} to {$target->value}.");
}
return $target;
}
from enum import StrEnum
class InvalidTransition(Exception):
pass
class OrderStatus(StrEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
def is_final(self) -> bool:
return self in (OrderStatus.DELIVERED, OrderStatus.CANCELLED)
def can_transition_to(self, nxt: "OrderStatus") -> bool:
allowed = {
OrderStatus.PENDING: {OrderStatus.PAID, OrderStatus.CANCELLED},
OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
OrderStatus.DELIVERED: set(),
OrderStatus.CANCELLED: set(),
}
return nxt in allowed[self]
@classmethod
def parse(cls, raw: str) -> "OrderStatus":
value = raw.strip().lower()
if value == "canceled":
value = "cancelled"
try:
return cls(value)
except ValueError:
raise ValueError(f"Unknown order status [{raw}]") from None
def transition(current: OrderStatus, nxt: str) -> OrderStatus:
target = OrderStatus.parse(nxt)
if not current.can_transition_to(target):
raise InvalidTransition(f"Cannot move from {current} to {target}")
return target

