Why Enums Deserve More Than a Simple Cast
Most teams stop at protected $casts = ['status' => StatusEnum::class] and call it done. That covers the happy path, but enums can do far more: they can own their own labels, drive route model binding, and back custom validation rules — all without a single string literal leaking into your domain logic.
Defining a Rich Backed Enum
<?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 Payment',
self::Confirmed => 'Confirmed',
self::Shipped => 'Shipped',
self::Cancelled => 'Cancelled',
};
}
public function isTerminal(): bool
{
return match($this) {
self::Shipped, self::Cancelled => true,
default => false,
};
}
/** @return list<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
The values() helper will pay dividends in validation shortly.
Eloquent: Beyond the Basic Cast
Laravel's AsEnum cast handles from() on read and ->value on write automatically. What it does not do is guard against invalid raw strings already in the database. Pair the cast with a model observer or a custom InboundCast if you need that guarantee.
// app/Models/Order.php
protected function casts(): array
{
return [
'status' => OrderStatus::class,
];
}
Now $order->status is always an OrderStatus instance, never a string. Conditional logic becomes:
if ($order->status->isTerminal()) {
throw new \DomainException('Order is already closed.');
}
Route Model Binding with Enums
Laravel 11+ lets you bind an enum directly as a route parameter via Route::enum():
// routes/api.php
use App\Enums\OrderStatus;
Route::get('/orders/status/{status}', [OrderController::class, 'byStatus'])
->whereIn('status', OrderStatus::values());
In the controller, type-hint the enum and Laravel resolves it automatically:
public function byStatus(OrderStatus $status): JsonResponse
{
$orders = Order::where('status', $status)->paginate();
return response()->json($orders);
}
If the segment doesn't match a valid case, Laravel returns a 404 — no manual tryFrom guard needed.
A Reusable EnumRule Validation Rule
Avoid scattering Rule::in(OrderStatus::values()) everywhere. Wrap it in a first-class rule:
<?php
namespace App\Rules;
use Illuminate\Contracts\Validation\Rule;
class EnumValue implements Rule
{
public function __construct(private readonly string $enumClass) {}
public function passes($attribute, $value): bool
{
return $this->enumClass::tryFrom($value) !== null;
}
public function message(): string
{
$values = implode(', ', array_column($this->enumClass::cases(), 'value'));
return "The :attribute must be one of: {$values}.";
}
}
Usage in a form request:
public function rules(): array
{
return [
'status' => ['required', new EnumValue(OrderStatus::class)],
];
}
The error message is self-documenting and stays in sync with the enum definition — no manual list to maintain.
Serialising Enums in API Resources
Avoid leaking raw values when the label is what the client needs:
// app/Http/Resources/OrderResource.php
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => [
'value' => $this->status->value,
'label' => $this->status->label(),
],
];
}
This keeps the API contract stable even if you rename enum cases internally.
PHP 8.3 Specifics Worth Knowing
PHP 8.3 introduced typed class constants, which means your enum can expose typed constants without a separate class:
enum OrderStatus: string
{
const DEFAULT = self::Pending; // typed, not just a string
// ...
}
This is useful when you need a sensible default in model factories or database seeders.
Key Takeaways
- Add domain methods (
label(),isTerminal()) directly to enums — they are first-class objects. - Use
Route::whereInwithEnum::values()for automatic 404 on invalid segments. - A generic
EnumValuerule keeps validation DRY and self-documenting. - Serialise both
valueandlabelin API resources to decouple clients from internal naming. - PHP 8.3 typed enum constants remove the need for separate constant classes.