Stop Treating Enums as Fancy Constants
Most Laravel codebases adopt backed enums, swap string constants for them, and call it done. That leaves the best part on the table: enums are first-class objects that can carry methods, implement interfaces, and integrate directly with Eloquent, validation, and your domain layer.
This article walks through a concrete OrderStatus domain, showing how to push logic into the enum itself rather than scattering it across services and controllers.
Defining a Behaviour-Rich Backed Enum
<?php
namespace App\Domain\Orders\Enums;
enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
/** Human-readable label for UI display. */
public function label(): string
{
return match($this) {
self::Pending => 'Awaiting Payment',
self::Confirmed => 'Confirmed',
self::Shipped => 'Shipped',
self::Cancelled => 'Cancelled',
};
}
/** Guard: can this status transition to $next? */
public function canTransitionTo(self $next): bool
{
return match($this) {
self::Pending => in_array($next, [self::Confirmed, self::Cancelled], true),
self::Confirmed => in_array($next, [self::Shipped, self::Cancelled], true),
self::Shipped => false,
self::Cancelled => false,
};
}
/** Collect only terminal statuses for query scopes. */
public static function terminal(): array
{
return [self::Shipped, self::Cancelled];
}
}
The transition guard lives inside the enum. No OrderStatusService needed.
Eloquent Cast — Zero Boilerplate
Laravel casts backed enums natively since Laravel 9:
use App\Domain\Orders\Enums\OrderStatus;
class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class,
];
}
Now $order->status is always an OrderStatus instance. No OrderStatus::from($order->getRawOriginal('status')) noise anywhere.
Enum-Aware Query Scopes
// In Order model
public function scopeTerminal(Builder $query): Builder
{
return $query->whereIn('status', OrderStatus::terminal());
}
public function scopeTransitionableTo(Builder $query, OrderStatus $next): Builder
{
$allowed = array_filter(
OrderStatus::cases(),
fn(OrderStatus $s) => $s->canTransitionTo($next)
);
return $query->whereIn('status', $allowed);
}
Usage:
$shippable = Order::transitionableTo(OrderStatus::Shipped)->get();
The query scope delegates the business rule back to the enum — single source of truth.
Validation Rule from Enum Cases
Avoid hardcoding allowed values in form requests:
use Illuminate\Validation\Rules\Enum;
public function rules(): array
{
return [
'status' => ['required', new Enum(OrderStatus::class)],
];
}
Laravel's built-in Enum rule reflects the cases automatically. Add a new case and validation updates for free.
Embedding Enums in Value Objects
When an enum alone isn't enough, compose it into a value object:
final readonly class StatusTransition
{
public function __construct(
public readonly OrderStatus $from,
public readonly OrderStatus $to,
public readonly \DateTimeImmutable $at,
) {
if (! $from->canTransitionTo($to)) {
throw new \DomainException(
"Cannot transition from {$from->label()} to {$to->label()}."
);
}
}
}
The value object enforces the invariant at construction time. Pass it to an action or event — never a raw string.
Pest Snapshot: Testing Enum Behaviour
use App\Domain\Orders\Enums\OrderStatus;
dataset('valid_transitions', [
[OrderStatus::Pending, OrderStatus::Confirmed],
[OrderStatus::Confirmed, OrderStatus::Shipped],
[OrderStatus::Confirmed, OrderStatus::Cancelled],
]);
it('allows valid transitions', function (OrderStatus $from, OrderStatus $to) {
expect($from->canTransitionTo($to))->toBeTrue();
})->with('valid_transitions');
it('blocks shipping a cancelled order', function () {
expect(OrderStatus::Cancelled->canTransitionTo(OrderStatus::Shipped))->toBeFalse();
});
Pure unit tests — no database, no HTTP stack.
Key Takeaways
- Attach behaviour to enums (
label(),canTransitionTo()) instead of service classes that switch on string values. - Use native Eloquent enum casts — they eliminate raw string comparisons in model code.
- Derive query scopes from enum methods so business rules stay in one place.
- Compose enums into value objects when you need invariant enforcement at construction time.
- Validate with
Enumrule — it reflects cases automatically, so adding a case never breaks validation silently. - Test enum logic in pure unit tests — fast, isolated, and expressive with Pest datasets.