Why Backed Enums Deserve More Than a Column Comment
Before PHP 8.1 enums, teams used string constants, defined() checks, or custom value-object classes to represent finite domain states. Laravel's native $casts support for backed enums removes almost all of that boilerplate — but only if you wire it up correctly across the full request lifecycle: database, validation, and serialization.
This article focuses on the practical gaps that trip up even experienced engineers.
Defining a Backed Enum with Domain Semantics
<?php
namespace App\Enums;
enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match($this) {
self::Pending => 'Awaiting confirmation',
self::Confirmed => 'Confirmed',
self::Shipped => 'On its way',
self::Cancelled => 'Cancelled',
};
}
public function isTerminal(): bool
{
return in_array($this, [self::Shipped, self::Cancelled], true);
}
}
Keep domain logic — isTerminal(), label(), transition guards — on the enum itself. This is the value-object pattern without a dedicated class.
Eloquent Cast: One Line, Full Type Safety
protected $casts = [
'status' => OrderStatus::class,
];
Laravel resolves the cast via Illuminate\Database\Eloquent\Casts\AsEnum internally. After this, $order->status is always an OrderStatus instance — never a raw string. Attempting to assign an invalid value throws a ValueError at the PHP level before any database call.
PHP 8.3 Readonly Properties and Enums
PHP 8.3 allows readonly properties on non-promoted constructor parameters. Combine this with a DTO:
readonly class PlaceOrderData
{
public function __construct(
public readonly string $customerId,
public readonly OrderStatus $status,
public readonly \DateTimeImmutable $placedAt,
) {}
}
The enum field is enforced at construction time — no setter, no mutation.
Validation: Enum Rule
Laravel ships an Enum rule that validates against any backed enum:
use Illuminate\Validation\Rules\Enum;
$request->validate([
'status' => ['required', new Enum(OrderStatus::class)],
]);
This rejects any string not present in the enum's cases. Pair it with a Form Request for clean controller code:
public function rules(): array
{
return [
'status' => ['required', new Enum(OrderStatus::class)],
];
}
API Resources: Serialize Intentionally
The default JSON serialization of an enum cast returns the backing value. That is usually correct, but sometimes you want the label too:
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => [
'value' => $this->status->value,
'label' => $this->status->label(),
],
];
}
}
Avoid leaking internal enum names (the case name) to API consumers — the backing value is your public contract.
Querying by Enum Value
Eloquent accepts enum instances directly in where clauses:
// Both forms work; prefer the enum instance for type safety.
$orders = Order::where('status', OrderStatus::Confirmed)->get();
For whereIn, pass an array of enum instances or extract values explicitly:
$active = Order::whereIn(
'status',
[OrderStatus::Pending, OrderStatus::Confirmed]
)->get();
Laravel's grammar layer calls ->value on each enum automatically.
Transition Guards on the Enum
Push state-machine logic onto the enum, not a service:
public function canTransitionTo(self $next): bool
{
return match($this) {
self::Pending => $next === self::Confirmed || $next === self::Cancelled,
self::Confirmed => $next === self::Shipped || $next === self::Cancelled,
default => false,
};
}
Your action or service then becomes a thin orchestrator:
if (! $order->status->canTransitionTo($newStatus)) {
throw new InvalidStatusTransitionException($order->status, $newStatus);
}
$order->update(['status' => $newStatus]);
Takeaways
- Cast every finite-state column to a backed enum — raw strings in models are a code smell.
- Keep domain logic (
label(),isTerminal(),canTransitionTo()) on the enum, not in services. - Use the
Enumvalidation rule to reject invalid values at the HTTP boundary. - Serialize intentionally in API resources; expose
value, not the PHP case name. - PHP 8.3 readonly properties pair naturally with enum-typed DTOs for immutable command objects.