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
CastsAttributeshandles 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.