Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 005 / 100

PHP enums: everything engineers need to know in practice

Replace magic strings with real types.

Expona Admin7 min read

Why magic strings break teams

We've all seen it: order status strings scattered across your codebase, spelled slightly differently in each place. One file uses 'pending', another uses 'Pending', and somewhere in a legacy job queue sits 'PENDING'. These magic strings create silent bugs that databases and type checkers can't catch. PHP enums: everything engineers need to know in practice starts with understanding that you need real types, not strings, to represent fixed sets of values. Enums give you that guarantee—compile-time safety for runtime logic.

Pure enums — Named cases without values

Pure enums are the simplest form. Each case is a named constant with no backing value. This works well when you're only concerned with identity, not storage or serialization.

php
enum Environment {
    case Local;
    case Staging;
    case Production;
}

You use them like this:

php
$env = Environment::Production;

if ($env === Environment::Production) {
    // Enable monitoring, disable debug output
    config('app.debug', false);
}

The enum value itself is an instance of that case. You can compare with === for identity, and the type system enforces that $env can only ever be one of those three cases. No null, no typo, no surprise.

Pure enums shine when you control all the code paths. You're not pulling data from a database or an external API; you're making decisions within your application logic. They're also useful as internal markers that never leave your process.

Backed enums — Cases with string or int values for storage

Most real-world scenarios demand backed enums. Each case has a scalar value—string or int—that you store in the database or send over the wire. This is where enums truly replace magic strings.

php
enum OrderStatus: string {
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

The backing type comes right after the enum name. You can access the backing value with the value property:

php
$status = OrderStatus::Paid;
echo $status->value; // 'paid'

When you serialize to JSON or store in the database, you get the string value automatically. When you instantiate from external data, use the tryFrom() or from() methods:

php
$status = OrderStatus::from('paid');
// Returns OrderStatus::Paid

$status = OrderStatus::tryFrom('unknown');
// Returns null instead of throwing

This is the critical piece: you've replaced the unsafe pattern of assigning user input directly to a property. Now you have a single point of validation. If from() succeeds, you have a real enum case. If it fails, you handle the error explicitly.

Backed enums also work with integer values, useful for legacy systems or when storage size matters:

php
enum Priority: int {
    case Low = 1;
    case Medium = 2;
    case High = 3;
    case Critical = 4;
}

You can't mix types within a single enum—it's all strings or all ints—but this constraint is itself a feature. It keeps your domain model clear.

Methods on enums — Add label() or color() methods for display logic

Enums are classes. You can add methods to them, turning them into rich objects that carry both data and behavior.

php
enum OrderStatus: string {
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    public function label(): string {
        return match($this) {
            self::Pending => 'Awaiting Payment',
            self::Paid => 'Processing',
            self::Shipped => 'On the Way',
            self::Delivered => 'Complete',
            self::Cancelled => 'Cancelled',
        };
    }

    public function color(): string {
        return match($this) {
            self::Pending => 'yellow',
            self::Paid => 'blue',
            self::Shipped => 'purple',
            self::Delivered => 'green',
            self::Cancelled => 'red',
        };
    }

    public function isTerminal(): bool {
        return $this === self::Delivered || $this === self::Cancelled;
    }
}

Now your view layer is much simpler:

php
// In a Blade template or controller response
@foreach($orders as $order)
    <span>
        {{ $order->status->label() }}
    </span>
@endforeach

This concentrates display logic where it belongs—inside the enum. You're not scattering if-else chains throughout controllers and views. Every time you add a new case, you update the enum's methods once, and all call sites automatically have the right behavior.

You can also add static methods for queries or factories:

php
enum OrderStatus: string {
    // ... cases and methods ...

    public static function activeStatuses(): array {
        return [self::Pending, self::Paid, self::Shipped];
    }
}

Then use it in database queries:

php
$activeOrders = Order::whereIn('status', 
    array_map(fn($s) => $s->value, OrderStatus::activeStatuses())
)->get();

Eloquent casts — Cast columns to enums automatically in Laravel models

Laravel's cast system automatically converts database columns to enums and back. This is where the power really emerges in production systems.

php
class Order extends Model {
    protected $casts = [
        'status' => OrderStatus::class,
    ];
}

From the database, the raw string 'paid' is automatically instantiated as OrderStatus::Paid when you load the model. When you save, it's serialized back to the string:

php
$order = Order::find(1);
echo $order->status::class; // OrderStatus

$order->status = OrderStatus::Shipped;
$order->save(); // Stores 'shipped' in the database

// You can also use from() in queries
$orders = Order::where('status', OrderStatus::Paid->value)->get();

JSON serialization works transparently too. When you return a model as JSON, the enum's backing value is used:

php
return response()->json($order);
// { "status": "paid", ... }

This means your API contracts stay stable—clients still receive strings—but internally your code is type-safe.

You can also cast multiple columns if you have several enums:

php
class Order extends Model {
    protected $casts = [
        'status' => OrderStatus::class,
        'priority' => Priority::class,
        'environment' => Environment::class,
    ];
}

When you query or filter, you can rely on the enum being present:

php
$order = Order::find(1);

if ($order->status->isTerminal()) {
    // No null check, no string comparison, no magic
    notify($order->customer);
}

Common mistakes

Forgetting to use ->value in queries. You can't do where('status', OrderStatus::Paid) directly. The database expects the string. Always use OrderStatus::Paid->value or rely on Laravel's where-in helpers that handle this automatically.

Trying to mix case types. You can't have case Pending = 'pending' and case Active = 1 in the same enum. Stick to one backing type per enum.

Throwing on from() without guarding. Using from() instead of tryFrom() in untrusted input paths can cause unhandled exceptions. Always use tryFrom() for external data.

Overloading methods with too much logic. Enums should encapsulate *domain knowledge* about that value (label, color, terminal state). Don't put business logic that depends on other entities or database calls inside them. Keep them lightweight and deterministic.

Forgetting to update tests. When you add a new enum case, every test that iterates over all cases or uses match() will need updating. This is actually good—it forces you to think through the implications—but it's easy to miss.

Key takeaways

  • Use pure enums for internal state that never leaves your process. Use backed enums for any value that goes to a database or API.
  • Add methods to enums to centralize domain logic like display labels, colors, and state transitions.
  • Leverage Laravel's cast system to automatically convert columns to enums on fetch and to backing values on save.
  • Always use tryFrom() for untrusted input; use from() only when you're certain the value exists.
  • Enums eliminate entire categories of bugs—typos, inconsistent casing, invalid state combinations—that string-based systems allow.
  • When a new enum case is added, your IDE and type checker will flag every call site that needs updating, turning hidden bugs into visible work.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles