Most PHP developers know that enums exist and that they replace class constants. But if your enums only look like a list of cases with string or integer backing values, you're using about 20% of what they offer. PHP enums can implement interfaces, contain methods, carry rich behavior, and integrate deeply with Laravel's Eloquent layer. They're, in many ways, specialized classes — and once you treat them that way, entire categories of code simplify.
This post picks up where the basics leave off. We'll build real, production-quality enums that do meaningful work.
Quick Recap: Basic and Backed Enums
A basic (unit) enum has cases with no underlying value. A backed enum ties each case to a string or int:
// Unit enum — no backing value
enum Suit
{
case Hearts;
case Diamonds;
case Clubs;
case Spades;
}
// Backed enum — each case has a string value
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
}
Backed enums give you ::from() and ::tryFrom() for hydrating from raw values, and ->value for accessing the backing value. That's table stakes. Now let's go deeper.
Adding Methods to Enums
Enums can contain methods, which means they can carry behavior alongside data. This is the first step toward replacing scattered helper functions and configuration arrays.
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting Review',
self::Processing => 'In Progress',
self::Shipped => 'On Its Way',
self::Delivered => 'Complete',
self::Cancelled => 'Cancelled',
};
}
public function color(): string
{
return match ($this) {
self::Pending => 'yellow',
self::Processing => 'blue',
self::Shipped => 'indigo',
self::Delivered => 'green',
self::Cancelled => 'red',
};
}
public function isFinal(): bool
{
return in_array($this, [self::Delivered, self::Cancelled]);
}
}
Now instead of a helper function that maps statuses to labels, the enum is the single source of truth:
// In a Blade template or Antlers view
$status = OrderStatus::Shipped;
echo $status->label(); // "On Its Way"
echo $status->color(); // "indigo"
if (! $status->isFinal()) {
echo 'This order can still be updated.';
}
Every piece of status-related logic lives on the enum itself. When you add a new case, the match expressions will throw an UnhandledMatchError if you forget to handle it — static analysis tools like PHPStan will catch this at build time.
Implementing Interfaces
Enums can implement interfaces, which means they can satisfy type contracts just like classes. This is where enums start replacing entire service patterns.
interface HasLabel
{
public function label(): string;
}
interface HasColor
{
public function color(): string;
}
enum Priority: int implements HasLabel, HasColor
{
case Low = 1;
case Medium = 2;
case High = 3;
case Critical = 4;
public function label(): string
{
return match ($this) {
self::Low => 'Low Priority',
self::Medium => 'Medium Priority',
self::High => 'High Priority',
self::Critical => 'Critical',
};
}
public function color(): string
{
return match ($this) {
self::Low => 'gray',
self::Medium => 'blue',
self::High => 'orange',
self::Critical => 'red',
};
}
}
The power here is polymorphism. You can write a component that accepts HasLabel and it works with any enum (or class) that implements the interface:
// A reusable badge component
function renderBadge(HasLabel&HasColor $item): string
{
return sprintf(
'<span class="badge badge-%s">%s</span>',
$item->color(),
$item->label(),
);
}
// Works with Priority, OrderStatus, or any enum implementing both interfaces
echo renderBadge(Priority::Critical);
echo renderBadge(OrderStatus::Shipped);
Notice the intersection type HasLabel&HasColor — we require both interfaces. This is how you build a consistent UI layer across your entire application without coupling components to specific enum types.
State Machine Transitions
One of the most elegant uses of enum methods is encoding state machine rules. Instead of scattering transition logic across controllers or service classes, the enum itself knows which transitions are valid:
enum OrderStatus: string
{
case Pending = 'pending';
case Processing = 'processing';
case Shipped = 'shipped';
case Delivered = 'delivered';
case Cancelled = 'cancelled';
/** @return array<self> */
public function allowedTransitions(): array
{
return match ($this) {
self::Pending => [self::Processing, self::Cancelled],
self::Processing => [self::Shipped, self::Cancelled],
self::Shipped => [self::Delivered],
self::Delivered => [],
self::Cancelled => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions());
}
public function transitionTo(self $next): self
{
if (! $this->canTransitionTo($next)) {
throw new \DomainException(
"Cannot transition from {$this->value} to {$next->value}."
);
}
return $next;
}
}
Usage is clean and self-documenting:
$status = OrderStatus::Pending;
$status = $status->transitionTo(OrderStatus::Processing); // Works
$status = $status->transitionTo(OrderStatus::Delivered); // Throws DomainException
This pattern eliminates the sprawling if ($order->status === 'pending' && $newStatus === 'processing') chains that inevitably appear in controllers. The rules live on the enum, and the enum is the only authority on what transitions are valid.
Enums in Eloquent Casts
Laravel natively supports casting model attributes to enums. This means every time you access a status column, you get a real enum instance — not a raw string.
use App\Enums\OrderStatus;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
/** @return array<string, string> */
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'priority' => Priority::class,
];
}
}
Now your model interactions are fully type-safe:
$order = Order::find(1);
// $order->status is an OrderStatus enum, not a string
echo $order->status->label(); // "Awaiting Review"
echo $order->status->color(); // "yellow"
if ($order->status->canTransitionTo(OrderStatus::Processing)) {
$order->status = OrderStatus::Processing;
$order->save();
}
// Query with enum values
$pending = Order::where('status', OrderStatus::Pending)->get();
Laravel handles the serialization automatically. When writing to the database, it stores the backing value. When reading, it hydrates the enum. You never deal with raw strings in your application code.
Replacing Scattered Constants
Many codebases have constants scattered across multiple files — a ROLE_ADMIN here, a STATUS_ACTIVE there, maybe an array of ALLOWED_FILE_TYPES somewhere else. Enums consolidate all of this.
Before: Constants Everywhere
// In User.php
class User extends Model
{
public const ROLE_ADMIN = 'admin';
public const ROLE_EDITOR = 'editor';
public const ROLE_VIEWER = 'viewer';
public function isAdmin(): bool
{
return $this->role === self::ROLE_ADMIN;
}
}
// In a controller somewhere
if ($user->role === User::ROLE_ADMIN || $user->role === User::ROLE_EDITOR) {
// allow editing
}
// In a form request
'role' => 'required|in:admin,editor,viewer',
// In a migration
$table->string('role')->default('viewer');
The constants live on the model, the validation rule is a hardcoded string, and the migration duplicates the allowed values. Nothing is connected.
After: A Single Enum
enum UserRole: string
{
case Admin = 'admin';
case Editor = 'editor';
case Viewer = 'viewer';
public function canEdit(): bool
{
return in_array($this, [self::Admin, self::Editor]);
}
public function canManageUsers(): bool
{
return $this === self::Admin;
}
/** @return array<string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
Now everything references the enum:
// Model
class User extends Model
{
protected function casts(): array
{
return ['role' => UserRole::class];
}
}
// Controller
if ($user->role->canEdit()) {
// allow editing
}
// Form request validation
use Illuminate\Validation\Rule;
'role' => ['required', Rule::enum(UserRole::class)],
// Migration
$table->string('role')->default(UserRole::Viewer->value);
Add a new role? Add one case to the enum. Every match expression that doesn't handle it will fail immediately. The validation rule automatically accepts only valid enum values. The single source of truth is the enum.
Utility Methods: Listing, Filtering, and Selecting
Enums don't have collection methods built in, but you can add static helper methods that make them practical for form selects, filter dropdowns, and API responses:
enum Priority: int implements HasLabel
{
case Low = 1;
case Medium = 2;
case High = 3;
case Critical = 4;
public function label(): string
{
return match ($this) {
self::Low => 'Low',
self::Medium => 'Medium',
self::High => 'High',
self::Critical => 'Critical',
};
}
/** @return array<int, string> */
public static function options(): array
{
return collect(self::cases())
->mapWithKeys(fn (self $case) => [$case->value => $case->label()])
->all();
}
/** @return array<self> */
public static function elevated(): array
{
return [self::High, self::Critical];
}
}
// Build a <select> dropdown
$options = Priority::options();
// [1 => 'Low', 2 => 'Medium', 3 => 'High', 4 => 'Critical']
// Filter tickets by elevated priority
$urgent = Ticket::whereIn('priority', array_column(Priority::elevated(), 'value'))->get();
Enums with Constants
Enums can also contain constants, which is useful for grouping metadata that doesn't vary per case:
enum SubscriptionPlan: string
{
public const TRIAL_DAYS = 14;
public const MAX_SEATS_FREE = 3;
case Free = 'free';
case Starter = 'starter';
case Professional = 'professional';
case Enterprise = 'enterprise';
public function maxSeats(): int
{
return match ($this) {
self::Free => self::MAX_SEATS_FREE,
self::Starter => 10,
self::Professional => 50,
self::Enterprise => PHP_INT_MAX,
};
}
public function monthlyPriceCents(): int
{
return match ($this) {
self::Free => 0,
self::Starter => 2900,
self::Professional => 9900,
self::Enterprise => 29900,
};
}
public function hasFeature(string $feature): bool
{
$features = match ($this) {
self::Free => ['basic_reports'],
self::Starter => ['basic_reports', 'api_access'],
self::Professional => ['basic_reports', 'api_access', 'advanced_reports', 'webhooks'],
self::Enterprise => ['basic_reports', 'api_access', 'advanced_reports', 'webhooks', 'sso', 'audit_log'],
};
return in_array($feature, $features);
}
}
This replaces the common pattern of having a plans.php config file or a database table of plan features. The enum is the config — version-controlled, type-safe, and always in sync.
Testing Enums Effectively
Because enums carry behavior, they deserve tests. The good news is that enum tests are simple and fast — no database, no HTTP, just pure logic.
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
class OrderStatusTest extends TestCase
{
#[Test]
public function pending_can_transition_to_processing(): void
{
$status = OrderStatus::Pending;
$this->assertTrue($status->canTransitionTo(OrderStatus::Processing));
}
#[Test]
public function delivered_cannot_transition_anywhere(): void
{
$status = OrderStatus::Delivered;
$this->assertEmpty($status->allowedTransitions());
}
#[Test]
public function invalid_transition_throws_exception(): void
{
$this->expectException(\DomainException::class);
OrderStatus::Pending->transitionTo(OrderStatus::Delivered);
}
#[Test]
#[DataProvider('labelProvider')]
public function each_status_has_a_label(OrderStatus $status): void
{
$this->assertNotEmpty($status->label());
}
/** @return array<string, array{OrderStatus}> */
public static function labelProvider(): array
{
return collect(OrderStatus::cases())
->mapWithKeys(fn ($case) => [$case->name => [$case]])
->all();
}
}
The data provider test guarantees that every case has a label. If someone adds a new case and forgets to update the label() method, the test fails immediately.
Common Pitfalls
A few things to watch for when working with enums:
Enums can't have instance properties. Unlike classes, enum cases are singletons. You can't do $this->someProperty = $value inside an enum. If you need per-instance state, you need a class instead. Enum methods should derive everything from $this (the current case) and method arguments.
Comparison uses identity, not equality. Enum cases are singletons, so OrderStatus::Pending === OrderStatus::Pending is always true. However, don't compare the backing value directly — use the enum instance. Write $order->status === OrderStatus::Pending, not $order->status->value === 'pending'.
Use ::tryFrom() for external input. The ::from() method throws a ValueError if the value doesn't match any case. When dealing with user input, API payloads, or imported data, use ::tryFrom() which returns null on failure.
When Not to Use Enums
Enums are perfect when the set of values is fixed and known at compile time. They're the wrong tool when:
The values are user-defined or change frequently. If your client can create new categories, statuses, or tags through the admin panel, those belong in a database table, not an enum. Changing an enum requires a code deployment.
The set is very large. An enum with 50+ cases is a code smell. At that point, you probably need a database lookup table or a config file that can be updated independently of your code.
You need instance state. If each instance needs to carry different data (beyond the fixed backing value), use a class or a readonly DTO instead.
Make Your Enums Work Harder
PHP enums aren't just a type-safe replacement for constants. They're a tool for consolidating behavior, enforcing business rules, and eliminating the kind of scattered, duplicated logic that makes codebases hard to maintain. Start by identifying the constants and config arrays in your codebase that represent a fixed set of options. Convert them to enums. Add a label() method. Add a color() method. Add transition rules. Cast them in your Eloquent models. Validate against them in your form requests.
I started treating enums as first-class citizens about a year ago, and I genuinely can't imagine going back to scattered constants. Once you make the switch, you'll feel the same way.