Typed PHP 8.3 Enums as Eloquent Casts, Route Parameters, and Validation Rules
#laravel #php8.3 #enums #eloquent #validation

Typed PHP 8.3 Enums as Eloquent Casts, Route Parameters, and Validation Rules

1 min read Mohamed Said Mohamed Said

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::whereIn with Enum::values() for automatic 404 on invalid segments.
  • A generic EnumValue rule keeps validation DRY and self-documenting.
  • Serialise both value and label in API resources to decouple clients from internal naming.
  • PHP 8.3 typed enum constants remove the need for separate constant classes.

Found this useful?

Frequently Asked Questions

3 questions
Q01 What happens when an invalid enum value is stored in the database and Eloquent tries to cast it?
Laravel's built-in AsEnum cast calls `from()`, which throws a ValueError for unknown values. If you need graceful degradation, implement a custom cast that uses `tryFrom()` and returns a nullable or a default case instead.
Q02 Can I use a unit enum (no backing type) as an Eloquent cast?
No. Eloquent's enum cast requires a backed enum (string or int) because it needs a scalar value to persist to and hydrate from the database column.
Q03 How do I use the same EnumValue rule for multiple enums without duplicating code?
The `EnumValue` rule shown accepts any backed enum class as a constructor argument, so `new EnumValue(PaymentStatus::class)` works just as well. You can also register it as a macro on the `Validator` facade if you prefer a string-based syntax.

Continue reading

More Articles

View all