PHP 8.3+ Typed Enums, Backed Casts, and Readonly Properties in Modern Laravel
#laravel #php8.3 #enums #eloquent #domain-modeling

PHP 8.3+ Typed Enums, Backed Casts, and Readonly Properties in Modern Laravel

1 min read Mohamed Said Mohamed Said

Why Primitive Obsession Still Haunts Laravel Apps

Most Laravel codebases pass raw strings and integers everywhere — 'active', 1, 'usd'. These primitives carry no type information, require defensive checks at every boundary, and make refactoring risky. PHP 8.1 introduced backed enums; PHP 8.3 tightened the type system further with typed class constants and improved readonly semantics. Laravel has had first-class enum casting since v9. Combining all three gives you domain models that are self-documenting and compiler-verified.

Defining a Backed Enum with Behaviour

Don't treat enums as dumb constants. Add methods and implement interfaces directly on the 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 in_array($this, [self::Shipped, self::Cancelled], true);
    }

    /** @return list<self> */
    public static function transitionableFrom(self $current): array
    {
        return match($current) {
            self::Pending   => [self::Confirmed, self::Cancelled],
            self::Confirmed => [self::Shipped, self::Cancelled],
            default         => [],
        };
    }
}

The transitionableFrom method encodes domain rules inside the enum itself — no external state machine library needed for simple flows.

Eloquent Enum Casting

Laravel resolves backed enums automatically when you use the enum's FQCN as the cast value.

use App\Enums\OrderStatus;

class Order extends Model
{
    protected $casts = [
        'status'   => OrderStatus::class,
        'currency' => Currency::class, // another backed enum
    ];
}

Now $order->status is always an OrderStatus instance. Comparisons become $order->status === OrderStatus::Shipped — no more === 'shipped' scattered across services.

Storing Enum Arrays with a Custom Cast

Sometimes a column holds a JSON array of enum values — e.g., allowed payment methods.

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

/** @implements CastsAttributes<list<PaymentMethod>, list<string>> */
class EnumArrayCast implements CastsAttributes
{
    public function __construct(private readonly string $enumClass) {}

    public function get($model, string $key, mixed $value, array $attributes): array
    {
        return array_map(
            fn(string $v) => $this->enumClass::from($v),
            json_decode($value ?? '[]', true)
        );
    }

    public function set($model, string $key, mixed $value, array $attributes): string
    {
        return json_encode(array_map(fn($e) => $e->value, $value));
    }
}

Register it on the model:

protected $casts = [
    'allowed_methods' => EnumArrayCast::class.':'.PaymentMethod::class,
];

Readonly Properties for Immutable Value Objects

PHP 8.2+ readonly classes and PHP 8.3's typed class constants pair well with enums in value objects.

readonly class Money
{
    public function __construct(
        public readonly int    $amount,   // stored in minor units
        public readonly Currency $currency,
    ) {
        if ($amount < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative.');
        }
    }

    public function add(self $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException('Currency mismatch.');
        }
        return new self($this->amount + $other->amount, $this->currency);
    }

    public function format(): string
    {
        return number_format($this->amount / 100, 2).' '.$this->currency->value;
    }
}

Cast Money via a custom CastsAttributes implementation that serialises to two columns using castUsing and mergeAttributesFromCacheUsing — or simply store JSON and decode into the readonly class.

Enum-Aware Validation Rules

Laravel's Rule::enum() validates that an incoming string maps to a valid case:

use Illuminate\Validation\Rules\Enum;

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

Pair this with a Form Request and your controller receives a validated primitive that you immediately cast — or better, let the model cast handle it after fill().

Key Takeaways

  • Backed enums belong in your domain layer, not just as database constants — add label(), transition guards, and interface implementations directly.
  • Laravel's native enum cast ($casts = ['status' => MyEnum::class]) eliminates string comparisons at zero cost.
  • Custom CastsAttributes handles non-trivial shapes like enum arrays or multi-column value objects.
  • Readonly classes enforce immutability at the language level — no need for private setters or clone guards.
  • Rule::enum() closes the validation gap so invalid strings never reach your domain layer.
  • PHP 8.3 typed class constants (const OrderStatus INITIAL = OrderStatus::Pending) let you document intent inside the enum without magic strings.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use a backed enum as a route model binding key in Laravel?
Yes. Implement `Illuminate\Contracts\Routing\UrlRoutable` on the enum or resolve it manually in a route service provider. More commonly, you bind the parent Eloquent model and let the enum cast handle conversion after retrieval — avoiding the need to route-bind the enum directly.
Q02 How do I handle database migrations when renaming an enum case?
Backed enums store their raw value (string or int), not the case name. Rename the PHP case freely — only the `value` matters in the database. If you need to change the stored value itself, write a data migration to UPDATE existing rows before deploying the new enum definition.
Q03 Are readonly classes compatible with Eloquent model hydration?
Readonly classes cannot be used as Eloquent models directly because Eloquent mutates properties during hydration. Use them as value objects inside a custom CastsAttributes implementation, where you construct a fresh readonly instance from the raw database value in the cast's get() method.

Continue reading

More Articles

View all