Laravel Typed Enums as First-Class Citizens: Casts, Rules, and PHP 8.3 Features
#laravel #php #enums #eloquent #api

Laravel Typed Enums as First-Class Citizens: Casts, Rules, and PHP 8.3 Features

3 min read Mohamed Said Mohamed Said

Why Enums Deserve More Than a CastsAttributes Afterthought

Most teams adopt backed enums, slap ->casts on the model, and call it done. That leaves a lot of safety on the table. Enums can own their own validation logic, drive route resolution, and — with PHP 8.3 typed class constants — become genuinely self-documenting domain primitives.


1. Backed Enums as Eloquent Casts

Laravel resolves any BackedEnum class directly in the $casts array:

// app/Enums/OrderStatus.php
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 => 'Order Confirmed',
            self::Shipped   => 'On Its Way',
            self::Cancelled => 'Cancelled',
        };
    }

    public function isTerminal(): bool
    {
        return in_array($this, [self::Shipped, self::Cancelled], true);
    }
}
// app/Models/Order.php
protected $casts = [
    'status' => OrderStatus::class,
];

Now $order->status is always an OrderStatus instance — never a raw string. Calling $order->status->label() is safe everywhere without defensive checks.


2. Enum-Aware Validation Rules

Laravel ships Rule::enum(), but you can push logic into the enum itself for reuse across HTTP and CLI contexts:

use Illuminate\Validation\Rules\Enum;

// In a Form Request
public function rules(): array
{
    return [
        'status' => ['required', new Enum(OrderStatus::class)],
    ];
}

For transitions, a custom rule keeps the policy inside the enum:

enum OrderStatus: string
{
    // ... cases above ...

    /** @return self[] */
    public function allowedTransitions(): array
    {
        return match($this) {
            self::Pending   => [self::Confirmed, self::Cancelled],
            self::Confirmed => [self::Shipped,   self::Cancelled],
            default         => [],
        };
    }
}
// app/Rules/ValidStatusTransition.php
class ValidStatusTransition 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 || ! in_array($next, $this->current->allowedTransitions(), true)) {
            $fail("Cannot transition from {$this->current->value} to {$value}.");
        }
    }
}

The rule is instantiated with the current status, so the transition matrix lives in one place.


3. Route Model Binding with Enums

Bind an enum directly in a route without a custom resolver:

// routes/api.php
Route::get('/orders/status/{status}', [OrderController::class, 'byStatus']);
// app/Http/Controllers/OrderController.php
public function byStatus(OrderStatus $status): JsonResponse
{
    $orders = Order::where('status', $status)->paginate();
    return response()->json($orders);
}

Laravel automatically calls OrderStatus::from($routeValue) and returns a 404 if the value is invalid — zero boilerplate.


4. PHP 8.3 Typed Class Constants

PHP 8.3 allows typed constants on enums, which is perfect for associating metadata without a separate config file:

enum OrderStatus: string
{
    case Pending   = 'pending';
    case Confirmed = 'confirmed';
    case Shipped   = 'shipped';
    case Cancelled = 'cancelled';

    // Typed constant — enforced by the engine
    const array TERMINAL = [self::Shipped, self::Cancelled];
    const string DEFAULT  = self::Pending->value;
}

Now OrderStatus::TERMINAL is a typed array constant — no docblock needed, and static analysis tools understand it without plugins.


5. Serialising Enums in API Resources

Avoid leaking raw values or accidentally serialising the enum object:

// app/Http/Resources/OrderResource.php
public function toArray(Request $request): array
{
    return [
        'id'     => $this->id,
        'status' => [
            'value'      => $this->status->value,
            'label'      => $this->status->label(),
            'terminal'   => $this->status->isTerminal(),
        ],
    ];
}

Frontend consumers get a stable contract with both machine and human-readable representations.


Takeaways

  • Use $casts with the enum class directly — Laravel handles BackedEnum natively.
  • Push transition logic into the enum itself; validation rules just delegate to it.
  • Route model binding resolves backed enums automatically with a 404 on invalid values.
  • PHP 8.3 typed constants let enums carry structured metadata without external config.
  • Serialise enums explicitly in API resources to keep frontend contracts stable.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does Laravel automatically validate enum values when casting?
No. The cast silently returns null for invalid values when using `tryFrom` internally. You still need `Rule::enum()` or a custom validation rule in your Form Request to reject bad input before it reaches the model.
Q02 Can I use a pure (non-backed) enum as an Eloquent cast?
Not directly. Eloquent's built-in enum cast requires a `BackedEnum` because it needs a scalar value to store in the database. For pure enums you must implement a custom `CastsAttributes` class.
Q03 Are PHP 8.3 typed constants on enums supported by PHPStan and Psalm?
Yes. Both PHPStan (level 6+) and Psalm understand typed class constants on enums as of their current stable releases, giving you full static analysis coverage without extra stubs.

Continue reading

More Articles

View all