Why Enums Deserve More Than a label() Method
Most Laravel codebases treat backed enums as glorified constants — a cases() call for a select box and a label() helper bolted on. That leaves real domain logic scattered across services, form requests, and Blade templates. PHP 8.3 enums support interface implementation, constants, and static methods. Laravel 11+ wires them into the framework at every layer. Let's use all of it.
Modelling Domain State with Interface-Backed Enums
Start by defining a contract your enums must honour:
interface HasColour
{
public function colour(): string;
}
interface Transitionable
{
/** @return static[] */
public function allowedTransitions(): array;
}
Now implement both on a OrderStatus enum:
enum OrderStatus: string implements HasColour, Transitionable
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function colour(): string
{
return match($this) {
self::Pending => 'yellow',
self::Confirmed => 'blue',
self::Shipped => 'green',
self::Cancelled => 'red',
};
}
public function allowedTransitions(): array
{
return match($this) {
self::Pending => [self::Confirmed, self::Cancelled],
self::Confirmed => [self::Shipped, self::Cancelled],
self::Shipped => [],
self::Cancelled => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions(), strict: true);
}
}
The transition guard lives on the enum itself — no service class required for this logic.
Eloquent Cast: Zero Boilerplate
Laravel casts backed enums natively. Declare the cast and you get type-safe attribute access:
class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class,
];
}
// Usage
$order->status->colour(); // 'blue'
$order->status->canTransitionTo(OrderStatus::Shipped); // true/false
No accessor, no mutator, no string comparison scattered across the codebase.
Route Model Binding for Enum Segments
Laravel 11 supports explicit enum binding in routes. Register it in AppServiceProvider:
Route::get('/orders/status/{status}', OrdersByStatusController::class)
->whereIn('status', array_column(OrderStatus::cases(), 'value'));
Or use the built-in enum binding — Laravel resolves the backed value automatically:
Route::get('/orders/status/{status}', function (OrderStatus $status) {
return Order::where('status', $status)->paginate();
});
A request to /orders/status/invalid returns a 404 without a single line of guard code.
Validation Rule from Enum Cases
Avoid hardcoding allowed values in form requests:
use Illuminate\Validation\Rules\Enum;
class TransitionOrderRequest extends FormRequest
{
public function rules(): array
{
return [
'status' => ['required', new Enum(OrderStatus::class)],
];
}
}
Add a custom rule that also checks the transition is legal:
use Illuminate\Contracts\Validation\ValidationRule;
class ValidTransition 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 || ! $this->current->canTransitionTo($next)) {
$fail("Cannot transition from {$this->current->value} to {$value}.");
}
}
}
Inject the current order into the request and compose:
public function rules(): array
{
return [
'status' => ['required', new Enum(OrderStatus::class), new ValidTransition($this->order->status)],
];
}
PHP 8.3 Enum Constants for Grouping
PHP 8.3 allows typed constants on enums, useful for grouping cases without a helper method:
enum OrderStatus: string implements HasColour, Transitionable
{
// ... cases above ...
const OPEN_STATES = [self::Pending, self::Confirmed];
const CLOSED_STATES = [self::Shipped, self::Cancelled];
}
// Scope
public function scopeOpen(Builder $query): void
{
$query->whereIn('status', array_column(OrderStatus::OPEN_STATES, 'value'));
}
Takeaways
- Implement domain interfaces on enums to keep behaviour co-located with state.
- Laravel's native enum cast eliminates accessor/mutator boilerplate entirely.
- Route model binding resolves backed enums automatically and returns 404 on invalid values.
- Compose the
Enumvalidation rule with custom rules for business-logic guards. - PHP 8.3 enum constants let you group cases without polluting models or services.
tryFrom()is your safe entry point whenever deserialising external input.