Laravel Enum Casts, Backed Enums, and Value Semantics in PHP 8.3
#laravel #php8.3 #enums #eloquent #type-safety

Laravel Enum Casts, Backed Enums, and Value Semantics in PHP 8.3

1 min read Mohamed Said Mohamed Said

Why Backed Enums Deserve More Than a Column Comment

Before PHP 8.1 enums, teams used string constants, defined() checks, or custom value-object classes to represent finite domain states. Laravel's native $casts support for backed enums removes almost all of that boilerplate — but only if you wire it up correctly across the full request lifecycle: database, validation, and serialization.

This article focuses on the practical gaps that trip up even experienced engineers.


Defining a Backed Enum with Domain Semantics

<?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 confirmation',
            self::Confirmed => 'Confirmed',
            self::Shipped   => 'On its way',
            self::Cancelled => 'Cancelled',
        };
    }

    public function isTerminal(): bool
    {
        return in_array($this, [self::Shipped, self::Cancelled], true);
    }
}

Keep domain logic — isTerminal(), label(), transition guards — on the enum itself. This is the value-object pattern without a dedicated class.


Eloquent Cast: One Line, Full Type Safety

protected $casts = [
    'status' => OrderStatus::class,
];

Laravel resolves the cast via Illuminate\Database\Eloquent\Casts\AsEnum internally. After this, $order->status is always an OrderStatus instance — never a raw string. Attempting to assign an invalid value throws a ValueError at the PHP level before any database call.

PHP 8.3 Readonly Properties and Enums

PHP 8.3 allows readonly properties on non-promoted constructor parameters. Combine this with a DTO:

readonly class PlaceOrderData
{
    public function __construct(
        public readonly string      $customerId,
        public readonly OrderStatus $status,
        public readonly \DateTimeImmutable $placedAt,
    ) {}
}

The enum field is enforced at construction time — no setter, no mutation.


Validation: Enum Rule

Laravel ships an Enum rule that validates against any backed enum:

use Illuminate\Validation\Rules\Enum;

$request->validate([
    'status' => ['required', new Enum(OrderStatus::class)],
]);

This rejects any string not present in the enum's cases. Pair it with a Form Request for clean controller code:

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

API Resources: Serialize Intentionally

The default JSON serialization of an enum cast returns the backing value. That is usually correct, but sometimes you want the label too:

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'     => $this->id,
            'status' => [
                'value' => $this->status->value,
                'label' => $this->status->label(),
            ],
        ];
    }
}

Avoid leaking internal enum names (the case name) to API consumers — the backing value is your public contract.


Querying by Enum Value

Eloquent accepts enum instances directly in where clauses:

// Both forms work; prefer the enum instance for type safety.
$orders = Order::where('status', OrderStatus::Confirmed)->get();

For whereIn, pass an array of enum instances or extract values explicitly:

$active = Order::whereIn(
    'status',
    [OrderStatus::Pending, OrderStatus::Confirmed]
)->get();

Laravel's grammar layer calls ->value on each enum automatically.


Transition Guards on the Enum

Push state-machine logic onto the enum, not a service:

public function canTransitionTo(self $next): bool
{
    return match($this) {
        self::Pending   => $next === self::Confirmed || $next === self::Cancelled,
        self::Confirmed => $next === self::Shipped   || $next === self::Cancelled,
        default         => false,
    };
}

Your action or service then becomes a thin orchestrator:

if (! $order->status->canTransitionTo($newStatus)) {
    throw new InvalidStatusTransitionException($order->status, $newStatus);
}
$order->update(['status' => $newStatus]);

Takeaways

  • Cast every finite-state column to a backed enum — raw strings in models are a code smell.
  • Keep domain logic (label(), isTerminal(), canTransitionTo()) on the enum, not in services.
  • Use the Enum validation rule to reject invalid values at the HTTP boundary.
  • Serialize intentionally in API resources; expose value, not the PHP case name.
  • PHP 8.3 readonly properties pair naturally with enum-typed DTOs for immutable command objects.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I cast a nullable column to a backed enum in Laravel?
Yes. Laravel's enum cast handles null natively — a null database value returns null rather than throwing a ValueError. Declare the property as `?OrderStatus` in your model's PHPDoc for accurate static analysis.
Q02 How do I store an enum in a migration without a custom DB type?
Use `$table->string('status')` or `$table->enum('status', array_column(OrderStatus::cases(), 'value'))`. The string column is simpler and avoids MySQL enum migration pain when cases are added later.
Q03 Does Laravel's Enum validation rule work with integer-backed enums?
Yes. The `Enum` rule calls `from()` internally and accepts both string and integer backing types. Ensure your request input is cast to the correct PHP type before validation if you use integer-backed enums.

Continue reading

More Articles

View all