Laravel Enums as First-Class Citizens: Casts, Rules, and Eloquent Integration
#laravel #php #eloquent #enums

Laravel Enums as First-Class Citizens: Casts, Rules, and Eloquent Integration

1 min read Mohamed Said Mohamed Said

Why Enums Belong at the Domain Layer

Before PHP 8.1, status columns lived as magic strings or class constants — easy to mistype, impossible to enforce at the type level. Backed enums fix that. A string column that only ever holds 'active', 'suspended', or 'cancelled' should be represented as a UserStatus enum, not a raw string that leaks through every layer of your application.

This article covers the practical wiring: Eloquent casts, validation rules, query scopes, and a few sharp edges worth knowing.


Defining a Backed Enum

<?php

namespace App\Enums;

enum UserStatus: string
{
    case Active    = 'active';
    case Suspended = 'suspended';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match($this) {
            self::Active    => 'Active',
            self::Suspended => 'Suspended',
            self::Cancelled => 'Cancelled',
        };
    }

    public function isActive(): bool
    {
        return $this === self::Active;
    }
}

Keep behaviour that belongs to the status on the enum. isActive() is a domain rule — it should not live in a service class or, worse, scattered across if statements.


Eloquent Casting

Laravel has native support for backed enum casts since 9.x:

use App\Enums\UserStatus;

class User extends Model
{
    protected $casts = [
        'status' => UserStatus::class,
    ];
}

Now $user->status is always a UserStatus instance, never a raw string. Assigning an invalid value throws a ValueError at the PHP level before it ever reaches your database.

Storing Enums in JSON Columns

If you store an array of statuses in a JSONB column, the built-in cast won't help. Write a custom cast:

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;

class EnumArrayCast implements CastsAttributes
{
    public function __construct(private string $enumClass) {}

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

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

Usage:

protected $casts = [
    'allowed_statuses' => EnumArrayCast::class . ':' . UserStatus::class,
];

Validation Rules

Laravel ships an Enum rule that validates against a backed enum's values:

use Illuminate\Validation\Rules\Enum;

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

This rejects any value not present in the enum's cases(), giving you a clean validation boundary at the HTTP layer before the value ever touches your domain.


Query Scopes with Enums

Passing enum instances directly to Eloquent works because the query builder calls ->value on backed enums automatically:

// Both are equivalent:
User::where('status', UserStatus::Active)->get();
User::where('status', 'active')->get();

Encapsulate common filters as named scopes:

public function scopeWithStatus(
    Builder $query,
    UserStatus $status
): Builder {
    return $query->where('status', $status);
}

// Call site:
User::withStatus(UserStatus::Active)->paginate();

The type hint on $status means passing a raw string is a static analysis error — PHPStan and Psalm will catch it.


Sharp Edges

tryFrom vs fromfrom throws ValueError on unknown values; tryFrom returns null. Use from inside casts (fail loudly on corrupt data) and tryFrom when parsing untrusted external input.

Enum in route model binding — you can bind an enum to a route segment, but you must register it manually in RouteServiceProvider or use a closure:

Route::get('/users/{status}', function (UserStatus $status) {
    return User::withStatus($status)->paginate();
});

Laravel will call UserStatus::from($routeValue) automatically for backed enums.

Serialisation in jobs — enums serialise and restore cleanly in queued jobs. No extra work needed.


Takeaways

  • Cast every status/type column to a backed enum; eliminate raw strings from your domain layer.
  • Use the built-in Enum validation rule at HTTP boundaries — it's one line.
  • Add domain behaviour (label(), isActive()) directly on the enum, not in service classes.
  • Type-hint enum parameters in query scopes so static analysis enforces correctness.
  • Prefer from() inside casts and tryFrom() when parsing external or legacy data.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use a backed enum as a primary key or foreign key in Eloquent?
Technically yes, but it's rarely practical. Eloquent will cast the key correctly if you add it to `$casts`, but many internal framework operations assume scalar keys. Stick to using enums for status, type, and role columns rather than identifiers.
Q02 Does casting to an enum break when a legacy row contains an unrecognised value?
Yes — `Model::from()` throws a `ValueError` for unknown values. If your database has legacy data, write a migration to normalise values first, or use a custom cast that calls `tryFrom()` and falls back to a sensible default.
Q03 How do I expose enum values in an API resource without leaking the enum class?
Call `->value` explicitly in your resource: `'status' => $this->status->value`. If you want the label too, add `'status_label' => $this->status->label()`. Never return the enum object directly — JSON serialisation will produce the raw value string, but being explicit is clearer and survives refactors.

Continue reading

More Articles

View all