Laravel Enums as First-Class Domain Citizens: Typed Casts, Backed Values, and Behaviour
#laravel #php #enums #domain-driven-design #eloquent

Laravel Enums as First-Class Domain Citizens: Typed Casts, Backed Values, and Behaviour

1 min read Mohamed Said Mohamed Said

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 Enum rule — 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.

Found this useful?

Frequently Asked Questions

2 questions
Q01 Can I use a backed enum directly in a whereIn() query without converting to values?
Yes. Laravel's query builder accepts backed enum instances in whereIn() and where() calls since Laravel 10 — it automatically extracts the backing value via the BackedEnum interface.
Q02 What happens if a database column contains a value that doesn't match any enum case?
Eloquent will throw a ValueError at hydration time. Guard against legacy data by using a custom cast that falls back to a default case, or run a migration to normalise the column before switching to the enum cast.

Continue reading

More Articles

View all