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 from — from 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
Enumvalidation 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 andtryFrom()when parsing external or legacy data.