Typed Enums as First-Class Domain Citizens in Laravel with PHP 8.3
#laravel #php8.3 #enums #domain-driven-design #eloquent

Typed Enums as First-Class Domain Citizens in Laravel with PHP 8.3

3 min read Mohamed Said Mohamed Said

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 Enum validation 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use a backed enum as an Eloquent cast without any extra configuration?
Yes. Since Laravel 9, you can set the cast to the fully-qualified enum class name and Laravel handles serialisation and deserialisation automatically, including returning null for nullable columns.
Q02 What happens if an invalid value is stored in the database for an enum cast?
Laravel will throw a ValueError when it tries to hydrate the model. Guard against this with a database CHECK constraint or a migration that validates existing data before adding the cast.
Q03 Are enum constants introduced in PHP 8.3 or were they available earlier?
Basic enum constants (without type enforcement on the constant itself) were available since PHP 8.1 when enums launched. PHP 8.3 refined constant visibility and allowed typed constants, making grouping patterns more explicit.

Continue reading

More Articles

View all