Medium PHP

PHP Enums Beyond the Basics: Backed Enums, Methods, tryFrom and Eloquent Casts

One 'canceled' vs 'cancelled' typo can strand orders for days. Use backed enums with methods, interfaces, tryFrom and Eloquent casts to kill magic strings.

29 Oct, 2026 11 min Updated 23 Oct, 2026 8 Code blocks
Diagram
graph LR A[Grep magic strings] --> B[Audit DB values] B --> C[Create backed enum] C --> D[Add Eloquent cast] D --> E[Validate input with Rule enum] E --> F[Move logic into enum methods] F --> G[Delete old string constants]

A customer writes in: their order has been "processing" for nine days. You dig in and find a queue job that sets status = 'canceled' while every other part of the app checks for 'cancelled'. The refund listener never fired, the dashboard filter never matched, and nobody noticed because both strings are perfectly valid PHP. A grep finds the status strings in 40-odd files.

Native enums landed in PHP 8.1 and they fix exactly this class of bug. Most teams adopt them halfway, though: they declare case Paid = 'paid' and keep the business rules in if statements spread around the codebase. This tip goes further: backed enums as the single source of truth, behaviour living on the enum, interfaces and constants, safe parsing with tryFrom(), Eloquent casts, and a migration path off magic strings that does not break production data.

Pure Enums vs Backed Enums

An enum is a closed set of named values. PHP gives you two flavours:

enum Suit { case Hearts; case Spades; } // pure: no scalar value

// backed: every case maps to a unique int or string you can persist
enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';
}

echo OrderStatus::Paid->name;   // "Paid"  (the identifier in code)
echo OrderStatus::Paid->value;  // "paid"  (what goes into the database)

var_dump(OrderStatus::Paid === OrderStatus::from('paid')); // bool(true): cases are singletons

A pure enum has cases and nothing else. Use it for states that never leave the process: a parser mode, a UI variant, a strategy selector. A backed enum gives each case a scalar (int or string, nothing else) that must be unique and constant. That scalar is what you store in the database, put in JSON, and receive from forms.

A few facts worth knowing before you build on them:

  • ->name is the identifier you wrote in code. ->value is the persisted value. Renaming a case is a refactor; changing a value is a data migration.
  • Every case is a singleton object, so === works and is the comparison you want.
  • Backed enums implement BackedEnum (which extends UnitEnum); pure enums implement only UnitEnum. You can type-hint against those interfaces in generic code.
  • json_encode() turns a backed case into its value. A pure case cannot be serialised to JSON at all: with JSON_THROW_ON_ERROR you get a JsonException saying non-backed enums have no default serialization.

Prefer string backing: status = 3 in a support query tells nobody anything.

Put Behaviour on the Enum: Methods and match

The real payoff comes when the rules that belong to a status live next to it. Enums can have methods (instance and static), and match ($this) makes them compact:

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 => 'Waiting for payment',
            self::Paid => 'Paid',
            self::Shipped => 'On the way',
            self::Delivered => 'Delivered',
            self::Cancelled => 'Cancelled',
        };
    }

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

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Pending => [self::Paid, self::Cancelled],
            self::Paid => [self::Shipped, self::Cancelled],
            self::Shipped => [self::Delivered],
            self::Delivered, self::Cancelled => [],
        };
    }

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, $this->allowedTransitions(), true);
    }
}

Two details make this robust. First, there is no default arm in the match expressions. If someone adds case Refunded = 'refunded' and forgets to handle it, PHP throws UnhandledMatchError the first time that path runs, and a test that loops over cases() catches it before production. A default arm would silently give refunded orders the wrong label.

Second, the transition table now has exactly one home. Controllers ask $order->status->canTransitionTo(OrderStatus::Shipped) instead of re-implementing in_array($order->status, ['paid']). When product changes the rules, you change one method and the tests that cover it.

What enums cannot do: hold state. An enum may not declare properties, not even static ones, and cases cannot be constructed with new.

Constants, Static Methods and Interfaces

Enums can implement interfaces, use traits (as long as the trait declares no properties), declare constants, and have static methods. That lets them plug into generic code:

interface HasLabel
{
    public function label(): string;
}

trait ListsValues
{
    /** @return list<string> */
    public static function values(): array
    {
        return array_column(self::cases(), 'value');
    }
}

enum PaymentMethod: string implements HasLabel
{
    use ListsValues;

    case Card = 'card';
    case BankTransfer = 'bank_transfer';
    case Wallet = 'wallet';

    public const DEFAULT = self::Card;
    public const DEFAULT_VALUE = self::Card->value; // allowed since PHP 8.2

    public function label(): string
    {
        return match ($this) {
            self::Card => 'Credit / debit card',
            self::BankTransfer => 'Bank transfer',
            self::Wallet => 'E-wallet',
        };
    }
}

echo implode(',', PaymentMethod::values()); // card,bank_transfer,wallet

HasLabel lets a form component render any labelled enum. The ListsValues trait removes a line you would otherwise copy into every enum. Constants are handy for defaults: PaymentMethod::DEFAULT is a real case, so it is type-safe everywhere it is used. Since PHP 8.2 you can also read ->value inside a constant expression, which is useful for attribute arguments and migration defaults.

from() vs tryFrom(): Parsing Untrusted Input

Backed enums come with two constructors for turning a scalar back into a case:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Cancelled = 'cancelled';
}

// from(): use when an invalid value is a bug. Throws ValueError.
$status = OrderStatus::from('paid');

// tryFrom(): use at the boundary, where bad input is expected. Returns null.
function statusFromWebhook(array $payload): OrderStatus
{
    $raw = $payload['status'] ?? null;

    if (! is_string($raw)) {
        throw new InvalidArgumentException('Webhook payload has no status.');
    }

    // Normalise known aliases first.
    $normalised = match (strtolower(trim($raw))) {
        'canceled' => 'cancelled',
        default => strtolower(trim($raw)),
    };

    return OrderStatus::tryFrom($normalised)
        ?? throw new InvalidArgumentException("Unknown order status [{$raw}].");
}

echo statusFromWebhook(['status' => ' Canceled '])->name; // Cancelled

The rule of thumb:

  • from() when an invalid value means a bug in your own system: a value you wrote yourself, a config key, a column you control. It throws ValueError, and you want it loud.
  • tryFrom() at the edges: webhooks, CSV imports, query strings, anything a third party sends. It returns null, and the ?? throw idiom turns that into a domain-specific exception with a useful message.

Watch the types under declare(strict_types=1): an int-backed enum's from('1') throws a TypeError, not a ValueError, because the argument must be an int. Cast at the boundary ((int) $request->input('level')) after validating that it is numeric.

Eloquent Enum Casts in Laravel 13

Laravel casts a column straight to a backed enum. Reading gives you a case, writing accepts a case (or its value), and the query builder binds enum cases as their scalar value:

namespace App\Models;

use App\Enums\OrderStatus;
use App\Enums\PaymentMethod;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
use Illuminate\Database\Eloquent\Model;

final class Order extends Model
{
    protected $fillable = ['status', 'payment_method', 'allowed_methods'];

    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
            'payment_method' => PaymentMethod::class,
            'allowed_methods' => AsEnumCollection::of(PaymentMethod::class),
        ];
    }

    public function markAsPaid(): void
    {
        if (! $this->status->canTransitionTo(OrderStatus::Paid)) {
            throw new \DomainException("Cannot pay an order that is {$this->status->value}.");
        }

        $this->status = OrderStatus::Paid;
        $this->save();
    }

    public function scopeOpen(Builder $query): void
    {
        // The query builder binds BackedEnum cases as their ->value.
        $query->whereIn('status', [OrderStatus::Pending, OrderStatus::Paid, OrderStatus::Shipped]);
    }
}

AsEnumCollection::of() stores a JSON array of values and hydrates a collection of cases, which fits "allowed methods per merchant" style columns. For input, Rule::enum() validates against the cases, only() / except() narrow it, and $request->enum('status', OrderStatus::class) returns the case (or null):

namespace App\Http\Requests;

use App\Enums\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

final class UpdateOrderStatusRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status' => ['required', Rule::enum(OrderStatus::class)->only([OrderStatus::Shipped, OrderStatus::Cancelled])],
        ];
    }}

Route binding understands backed enums too: type-hint OrderStatus $status and /orders/status/bogus returns a 404 with no code from you.

For the column itself, keep a plain string (say varchar(32)) with ->default(OrderStatus::Pending->value) rather than a database ENUM type. Adding a case then needs no schema change, and the PHP enum stays the single source of truth.

Replacing Magic Strings Without Breaking Production

Migrating is a sequence, not a big bang, because the cast throws on the first value it cannot map:

graph LR A[Grep magic strings] --> B[Audit DB values] B --> C[Create backed enum] C --> D[Add Eloquent cast] D --> E[Validate input with Rule enum] E --> F[Move logic into enum methods] F --> G[Delete old string constants]
  1. Grep the strings, including views, JavaScript and SQL reports. This is your checklist.
  2. Audit the database. Before writing the cast, find out what is actually stored. Legacy rows always contain surprises:
-- Run this BEFORE adding the cast. Every row must map to a case.
SELECT status, COUNT(*) AS total
FROM orders
GROUP BY status
ORDER BY total DESC;
  1. Create the enum with the values the database really contains after cleanup.
  2. Add the cast. Every $order->status === 'paid' comparison is now silently false; fix them in the same pull request using the grep list.
  3. Validate input with Rule::enum() so new bad values cannot get in.
  4. Move logic into the enum: transitions, labels, colours, "is this final".
  5. Delete the old constants (Order::STATUS_PAID and friends) once nothing references them.

Steps 4 and 6 can ship in separate deploys; the audit must happen before the cast goes live.

Testing Enums with Pest

Enum logic is pure, so tests are fast and need no setup. Test the transition table with a dataset, loop over cases() so new cases cannot slip through untested, and add one database-level guard:

use App\Enums\OrderStatus;
use Illuminate\Support\Facades\DB;

it('allows only the documented transitions', function (OrderStatus $from, OrderStatus $to, bool $allowed) {
    expect($from->canTransitionTo($to))->toBe($allowed);
})->with([
    'pay a pending order' => [OrderStatus::Pending, OrderStatus::Paid, true],
    'ship a paid order' => [OrderStatus::Paid, OrderStatus::Shipped, true],
    'ship an unpaid order' => [OrderStatus::Pending, OrderStatus::Shipped, false],
    'revive a cancelled order' => [OrderStatus::Cancelled, OrderStatus::Pending, false],
]);

it('gives every case a label', function () {
    foreach (OrderStatus::cases() as $case) {
        expect($case->label())->toBeString()->not->toBeEmpty();
    }
});

it('has no status values in the database that the enum cannot read', function () {
    $unknown = DB::table('orders')
        ->distinct()
        ->pluck('status')
        ->reject(fn (string $value): bool => OrderStatus::tryFrom($value) !== null);

    expect($unknown)->toBeEmpty();
});

Run the last test against a staging snapshot and it tells you whether the cast will throw on real data. The cases() loop catches a new case without a label.

Common Pitfalls

Comparing a cast attribute to a string. After adding the cast, $order->status === 'paid' is always false because you are comparing an object to a string. No error, just wrong behaviour. Compare to the case: $order->status === OrderStatus::Paid.

Using cases as array keys. Enums are objects, so [Plan::Free => 10] throws a TypeError. Use $limits[Plan::Pro->value], or a WeakMap when you really need the case itself as the key.

A default arm in match. It defeats the exhaustiveness check that makes enums safer than strings. Leave it out unless "everything else" is a real business rule.

Invalid rows crash reads. If the database contains a value the enum does not know, reading the attribute throws ValueError. That happens on page loads and in queue workers, not just in the code that wrote the bad value. Clean data first, then cast.

Changing a case value casually. The value lives in rows, cache entries, queued jobs and API clients. Treat it like a column rename: add the new value, migrate data, remove the old one.

When to Use Enums and When Not To

Use a backed enum when the set of values is closed and owned by your code: order states, roles with hard-coded permissions, payment methods you actually integrate with, log levels, feature tiers. Adding a case is a code change that goes through review, which is exactly what you want for values that carry logic.

Use a pure enum for in-process choices that are never stored, like sort directions.

Do not use an enum when the list is data rather than code. Product categories that admins create, per-tenant ticket statuses, countries with editable metadata: those belong in a table with a foreign key. If adding a value should not require a deploy, it is not an enum.

The goal is simple: every place that cares about a status should ask the enum, and the database should only ever contain values the enum can read.

Interactive challenge

Try the challenge

Practice what you just learned. Write your solution, reveal hints if you get stuck.

Instructions

Implement an order state machine with a backed enum. OrderStatus (PHP: string-backed enum; Python: StrEnum) has the cases Pending/PENDING='pending', Paid/PAID='paid', Shipped/SHIPPED='shipped', Delivered/DELIVERED='delivered', Cancelled/CANCELLED='cancelled'. isFinal() / is_final() returns true only for Delivered and Cancelled. canTransitionTo(next) / can_transition_to(nxt) allows exactly: Pending to Paid or Cancelled, Paid to Shipped or Cancelled, Shipped to Delivered; everything else (including a status to itself and anything out of a final status) returns false. The static parse(raw) / classmethod parse(raw) trims whitespace, lowercases, maps the alias 'canceled' to 'cancelled' and returns the matching case; unknown values throw InvalidArgumentException (PHP) or raise ValueError (Python). The function transition(current, next) parses the next value, throws DomainException (PHP) or raises InvalidTransition (Python) when the move is not allowed, and returns the new status otherwise.

Starter code

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

    public function isFinal(): bool
    {
        // TODO: Delivered and Cancelled are final, everything else is not
        throw new LogicException('TODO');
    }

    public function canTransitionTo(self $next): bool
    {
        // TODO: Pending -> Paid|Cancelled, Paid -> Shipped|Cancelled, Shipped -> Delivered
        // TODO: final states allow nothing; a status never transitions to itself
        throw new LogicException('TODO');
    }

    public static function parse(string $raw): self
    {
        // TODO: trim + lowercase, map the alias 'canceled' to 'cancelled',
        //       then use tryFrom(); throw InvalidArgumentException for unknown values
        throw new LogicException('TODO');
    }
}

function transition(OrderStatus $current, string $next): OrderStatus
{
    // TODO: parse $next, throw DomainException if the move is not allowed,
    //       otherwise return the new status
    throw new LogicException('TODO');
}
python
from enum import StrEnum


class InvalidTransition(Exception):
    pass


class OrderStatus(StrEnum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

    def is_final(self) -> bool:
        # TODO: DELIVERED and CANCELLED are final, everything else is not
        raise NotImplementedError

    def can_transition_to(self, nxt: "OrderStatus") -> bool:
        # TODO: PENDING -> PAID|CANCELLED, PAID -> SHIPPED|CANCELLED, SHIPPED -> DELIVERED
        # TODO: final states allow nothing; a status never transitions to itself
        raise NotImplementedError

    @classmethod
    def parse(cls, raw: str) -> "OrderStatus":
        # TODO: strip + lowercase, map the alias "canceled" to "cancelled",
        #       raise ValueError for unknown values
        raise NotImplementedError


def transition(current: OrderStatus, nxt: str) -> OrderStatus:
    # TODO: parse nxt, raise InvalidTransition if the move is not allowed,
    #       otherwise return the new status
    raise NotImplementedError

Your solution

Hints

All hints revealed
Hint 1

Put the transition table in one match ($this) expression (or a dict in Python) with no default arm, so a new case cannot be forgotten

Hint 2

Normalise first (trim, lowercase, alias), then call tryFrom() and turn null into an exception with ?? throw

Hint 3

transition() should reuse parse() and canTransitionTo() instead of duplicating any rule

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

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

    public function canTransitionTo(self $next): bool
    {
        $allowed = match ($this) {
            self::Pending => [self::Paid, self::Cancelled],
            self::Paid => [self::Shipped, self::Cancelled],
            self::Shipped => [self::Delivered],
            self::Delivered, self::Cancelled => [],
        };

        return in_array($next, $allowed, true);
    }

    public static function parse(string $raw): self
    {
        $value = strtolower(trim($raw));

        if ($value === 'canceled') {
            $value = 'cancelled';
        }

        return self::tryFrom($value)
            ?? throw new InvalidArgumentException("Unknown order status [{$raw}].");
    }
}

function transition(OrderStatus $current, string $next): OrderStatus
{
    $target = OrderStatus::parse($next);

    if (! $current->canTransitionTo($target)) {
        throw new DomainException("Cannot move from {$current->value} to {$target->value}.");
    }

    return $target;
}
Solution · python
from enum import StrEnum


class InvalidTransition(Exception):
    pass


class OrderStatus(StrEnum):
    PENDING = "pending"
    PAID = "paid"
    SHIPPED = "shipped"
    DELIVERED = "delivered"
    CANCELLED = "cancelled"

    def is_final(self) -> bool:
        return self in (OrderStatus.DELIVERED, OrderStatus.CANCELLED)

    def can_transition_to(self, nxt: "OrderStatus") -> bool:
        allowed = {
            OrderStatus.PENDING: {OrderStatus.PAID, OrderStatus.CANCELLED},
            OrderStatus.PAID: {OrderStatus.SHIPPED, OrderStatus.CANCELLED},
            OrderStatus.SHIPPED: {OrderStatus.DELIVERED},
            OrderStatus.DELIVERED: set(),
            OrderStatus.CANCELLED: set(),
        }
        return nxt in allowed[self]

    @classmethod
    def parse(cls, raw: str) -> "OrderStatus":
        value = raw.strip().lower()
        if value == "canceled":
            value = "cancelled"
        try:
            return cls(value)
        except ValueError:
            raise ValueError(f"Unknown order status [{raw}]") from None


def transition(current: OrderStatus, nxt: str) -> OrderStatus:
    target = OrderStatus.parse(nxt)
    if not current.can_transition_to(target):
        raise InvalidTransition(f"Cannot move from {current} to {target}")
    return target

More from PHP