Why Enums Deserve More Than a CastsAttributes Afterthought
Most teams adopt backed enums, slap ->casts on the model, and call it done. That leaves a lot of safety on the table. Enums can own their own validation logic, drive route resolution, and — with PHP 8.3 typed class constants — become genuinely self-documenting domain primitives.
1. Backed Enums as Eloquent Casts
Laravel resolves any BackedEnum class directly in the $casts array:
// app/Enums/OrderStatus.php
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 Payment',
self::Confirmed => 'Order Confirmed',
self::Shipped => 'On Its Way',
self::Cancelled => 'Cancelled',
};
}
public function isTerminal(): bool
{
return in_array($this, [self::Shipped, self::Cancelled], true);
}
}
// app/Models/Order.php
protected $casts = [
'status' => OrderStatus::class,
];
Now $order->status is always an OrderStatus instance — never a raw string. Calling $order->status->label() is safe everywhere without defensive checks.
2. Enum-Aware Validation Rules
Laravel ships Rule::enum(), but you can push logic into the enum itself for reuse across HTTP and CLI contexts:
use Illuminate\Validation\Rules\Enum;
// In a Form Request
public function rules(): array
{
return [
'status' => ['required', new Enum(OrderStatus::class)],
];
}
For transitions, a custom rule keeps the policy inside the enum:
enum OrderStatus: string
{
// ... cases above ...
/** @return self[] */
public function allowedTransitions(): array
{
return match($this) {
self::Pending => [self::Confirmed, self::Cancelled],
self::Confirmed => [self::Shipped, self::Cancelled],
default => [],
};
}
}
// app/Rules/ValidStatusTransition.php
class ValidStatusTransition implements ValidationRule
{
public function __construct(private readonly OrderStatus $current) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$next = OrderStatus::tryFrom($value);
if ($next === null || ! in_array($next, $this->current->allowedTransitions(), true)) {
$fail("Cannot transition from {$this->current->value} to {$value}.");
}
}
}
The rule is instantiated with the current status, so the transition matrix lives in one place.
3. Route Model Binding with Enums
Bind an enum directly in a route without a custom resolver:
// routes/api.php
Route::get('/orders/status/{status}', [OrderController::class, 'byStatus']);
// app/Http/Controllers/OrderController.php
public function byStatus(OrderStatus $status): JsonResponse
{
$orders = Order::where('status', $status)->paginate();
return response()->json($orders);
}
Laravel automatically calls OrderStatus::from($routeValue) and returns a 404 if the value is invalid — zero boilerplate.
4. PHP 8.3 Typed Class Constants
PHP 8.3 allows typed constants on enums, which is perfect for associating metadata without a separate config file:
enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
// Typed constant — enforced by the engine
const array TERMINAL = [self::Shipped, self::Cancelled];
const string DEFAULT = self::Pending->value;
}
Now OrderStatus::TERMINAL is a typed array constant — no docblock needed, and static analysis tools understand it without plugins.
5. Serialising Enums in API Resources
Avoid leaking raw values or accidentally serialising the enum object:
// app/Http/Resources/OrderResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => [
'value' => $this->status->value,
'label' => $this->status->label(),
'terminal' => $this->status->isTerminal(),
],
];
}
Frontend consumers get a stable contract with both machine and human-readable representations.
Takeaways
- Use
$castswith the enum class directly — Laravel handlesBackedEnumnatively. - Push transition logic into the enum itself; validation rules just delegate to it.
- Route model binding resolves backed enums automatically with a 404 on invalid values.
- PHP 8.3 typed constants let enums carry structured metadata without external config.
- Serialise enums explicitly in API resources to keep frontend contracts stable.