Contents
- 1 PHP enums explained: From magic constants to real power
- 1.1 Why enums? The pain they solve
- 1.2 Pure enums: Simple, strong, safe
- 1.3 Backed enums: When you need real values
- 1.4 Methods and traits: Enums that think
- 1.5 Attributes: Metadata magic
- 1.6 Real-world: Laravel, Symfony, APIs
- 1.7 Gotchas and rules
- 1.8 Advanced patterns: Flexible, future-proof enums
- 1.9 Laravel deep dive: Forms, validation, blades
- 1.10 Pitfalls I learned the hard way
- 1.11 When to reach for enums
PHP enums explained: From magic constants to real power
Hey, fellow PHP developers. Picture this: it's 2 AM, your screen's the only light in the room, and you're wrestling with a status field that's either "pending", "approved", or—god forbid—some random string that slipped through because someone typed "PENDNG" in the database. We've all been there. Those moments when constants feel like a flimsy bandage on a gaping wound.
Then PHP 8.1 dropped enums, and suddenly, everything clicked. No more magic strings haunting your switch statements. No more "is this value valid?" paranoia. Enums aren't just syntactic sugar—they're a quiet revolution in how we think about state, validation, and type safety in PHP.
I've refactored entire codebases with them, felt that rush when a bug evaporates because the compiler catches it upfront. Today, let's unpack them step by step. Not as theory. As tools you can grab and use tomorrow.
Why enums? The pain they solve
Remember pre-8.1? We leaned on class constants.
class OrderStatus {
const PENDING = 'pending';
const APPROVED = 'approved';
const REJECTED = 'rejected';
}
Fine, until it's not. You assign $status = 'pendng';—typo city. Or worse, your IDE doesn't autocomplete. Switch statements? Exhaustive? Ha, good luck.
Enums fix this. They're closed sets of values. Type-safe. Objects under the hood. Singletons, even—same case, same object everywhere. Introduced in PHP 8.1, they scream "only these values exist, deal with it."[14]
Have you ever chased a production bug from a misspelled status? Enums prevent that heartbreak.
Pure enums: Simple, strong, safe
Start here. No backing value needed—just named cases.
enum Status {
case Pending;
case Approved;
case Rejected;
}
Use it:
$order = Status::Pending;
switch ($order) {
case Status::Pending:
echo "Hold tight.";
break;
case Status::Approved:
echo "Ship it!";
break;
case Status::Rejected:
echo "Better luck next time.";
}
Match expressions shine brighter—exhaustive by default.
$message = match ($order) {
Status::Pending => "On the way...",
Status::Approved => "Done deal.",
Status::Rejected => "No go.",
};
PHP yells if you miss a case. Pure bliss.
Status::cases() lists them all. Loop, validate, export—handy for dropdowns or APIs.
Backed enums: When you need real values
Database? API? Welcome backed enums. String or int behind the name.
enum ServerStatus: string {
case Pending = 'pending';
case Running = 'running';
case Stopped = 'stopped';
case Failed = 'failed';
}
Grab from raw data:
$status = ServerStatus::from('running'); // ServerStatus::Running
Rules? Strict. All backed same type—no mixing strings and ints. No union types in hints.
Why? Database columns love strings. $server->status = ServerStatus::Running->value; persists cleanly. Fetch back? ServerStatus::from($row['status']). Invalid? Throws ValueError. No silent failures.
I refactored a legacy Laravel app like this. One afternoon. Bugs gone. Migrations? Just cast the column: 'status' => ServerStatus::class in your model.
Methods and traits: Enums that think
Enums are classes. Add methods.
enum ServerStatus: string {
case Pending = 'pending';
case Running = 'running';
case Stopped = 'stopped';
case Failed = 'failed';
public function label(): string {
return match ($this) {
self::Pending => 'Initializing',
self::Running => 'Live',
self::Stopped => 'Paused',
self::Failed => 'Broken',
};
}
public function description(): string {
return match ($this) {
self::Pending => 'Server is being created.',
self::Running => 'Fully operational.',
self::Stopped => 'Stopped, restart anytime.',
self::Failed => 'Error state—check logs.',
};
}
}
Usage? $status = ServerStatus::Running; echo $status->label(); // "Live". Human-readable everywhere.
Traits supercharge. Common pattern: list options for forms.
trait InteractsWithEnumOptions {
public static function options(): array {
return collect(static::cases())
->mapWithKeys(fn (self $case) => [$case->value => $case->label()])
->toArray();
}
}
enum ServerStatus: string implements SomeContract {
use InteractsWithEnumOptions;
// cases...
}
Laravel controller: $options = ServerStatus::options();. Blade: <select>{{ $options }}</select>. Done.
Static methods? ServerStatus::default(); returns self::Running. JsonSerializable trait? Auto-exports values.
Attributes: Metadata magic
PHP attributes on cases? Game-changer for error handling, validation.
#[ErrorDetails('Email mismatch.', 403)]
case GoogleIdMismatch = 'google_id_mismatch';
Reflect them off:
public function getDescription(): string {
$reflection = new ReflectionEnumCase($this);
$attr = $reflection->getAttributes(ErrorDetails::class)[0]
->newInstance();
return $attr->description;
}
Denial reasons? HTTP codes? All baked in. Call DenialReason::EmailNotFound->getDescription(). No switch bloat.
Laravel? RoleEnum with descriptions for admin panels.
Real-world: Laravel, Symfony, APIs
Laravel: Eloquent casts. protected $casts = ['status' => ServerStatus::class];. Validates on save. Blade helpers galore.
Symfony: Doctrine maps enums to DB strings. StarshipStatusEnum for workflows—WAITING, IN_PROGRESS, COMPLETED.
APIs? Serialize to JSON: json_encode(ServerStatus::Running) => "running". Clients happy.
HTTP methods? Backed enum:
enum HttpMethod: string {
case GET = 'GET';
case POST = 'POST';
case PUT = 'PUT';
}
Attributes? Refactor hardcoded values.
Gotchas and rules
- Singletons:
Status::Pending === Status::Pendingalways true. - No mixing: Pure + backed? Nope.
- Serialization:
->valuefor JSON. Traits help. - Exhaustiveness: Match/switch must cover all, or error.
UnitEnumorBackedEnuminterfaces for polymorphism.
Edge case: from() throws on invalid. Wrap in try-catch or tryFrom() (PHP 8.2+? Check your version).
Advanced patterns: Flexible, future-proof enums
Ever need enums that evolve? Interfaces + traits.
interface EnumContract {
public function label(): string;
}
trait EnumOptions {
public static function names(): array {
return array_map(fn($e) => $e->name, static::cases());
}
public static function values(): array {
return array_map(fn($e) => $e->value, static::cases());
}
}
enum Suit: string implements EnumContract {
use EnumOptions;
case Hearts = '♥';
case Diamonds = '♦';
case Clubs = '♣';
case Spades = '♠';
public function label(): string {
return match($this) {
self::Hearts => 'Red hearts',
// ...
};
}
}
Suit::names()? Array of case names. Suit::values()? Symbols. JSON-ready with trait. Default case? public const DEFAULT = self::Hearts; public static function default(): static { return self::DEFAULT; }.
Symfony-style: Starship statuses in domain models. Clean, centralized.
Doors example—pure fun:
enum DoorState: string {
case Open = 'open';
case Closed = 'closed';
case Locked = 'locked';
}
class Door {
public function check(): string {
return match ($this->state) {
DoorState::Open => 'Come on in.',
DoorState::Closed => 'Knock first.',
DoorState::Locked => 'Key needed.',
};
}
}
Forgot Locked? PHP screams at compile time.
Laravel deep dive: Forms, validation, blades
Real project: User roles.
enum Role: string {
case Admin = 'admin';
case User = 'user';
case Guest = 'guest';
public function description(): string {
return match($this) {
self::Admin => 'Full access, handle with care.',
self::User => 'Standard rights, happy user.',
self::Guest => 'Visitor mode—be gentle.',
};
}
public function canEdit(): bool {
return $this !== self::Guest;
}
}
Model: $casts = ['role' => Role::class];. Controller:
$roles = Role::cases(); // or custom options()
return view('users.edit', compact('roles'));
Blade:
<select name="role">
@foreach($roles as $role)
<option value="{{ $role->value }}" {{ $user->role?->value === $role->value ? 'selected' : '' }}>
{{ $role->description() }}
</option>
@endforeach
</select>
<span>{{ $user->role?->description() }}</span>
@if($user->role?->canEdit()) Edit button @endif
Validation? role: Role. Laravel coerces, validates. Magic.
Pitfalls I learned the hard way
- Upgrading? PHP 8.1 min. Test
from()everywhere. - JSON? Custom serializer:
public function jsonSerialize(): string { return $this->value; }. - DB migrations: String columns. Length? Max case length + buffer.
- Lists:
array_column(array_map('strval', Status::cases()), 'value', 'name')for old forms. - Reflection heavy? Cache attributes—perf hit.
One bug: Assumed == compares values. Nope—identity. $a === $b for cases.
When to reach for enums
- Statuses, roles, types—anything finite.
- Replace constants, arrays of strings.
- Domain-driven? Model invariants.
- Not for: Open sets (tags, categories—use arrays).
I've swapped them into validation rules, event payloads, queue jobs. Code shrank. Confidence soared.
Enums feel like PHP growing up. From duct-tape constants to typed, expressive power. They whisper: "Your code can be safer, clearer." Next time you're defining states, pause. Reach for enum. That late-night debug session? It might just vanish.
And in the quiet after shipping clean code, there's this subtle satisfaction—like the machine finally understands you.