PHP Enums Instead of Constants and Magic Strings

PHP Enums Instead of Constants and Magic Strings

October 7, 2026
Magic strings, class constants and enums compared on what each prevents.

The bug was that one invoice would not appear in the paid list. The cause, after twenty minutes:

$invoice->update(['status' => 'Paid']);      // one place, capital P
Invoice::where('status', 'paid')->get();     // everywhere else

Nothing failed. The column is a varchar, the string fit, the write succeeded. The row simply stopped matching the query that was supposed to find it.

The usual first fix is class constants, and they are a real improvement:

class InvoiceStatus
{
    const DRAFT = 'draft';
    const PENDING = 'pending';
    const PAID = 'paid';
}

A typo is now a fatal error instead of a silent mismatch, which is most of the value. But nothing stops a function from receiving a string that is not one of them:

function markAs(Invoice $i, string $status) { ... }

markAs($invoice, InvoiceStatus::PAID);   // fine
markAs($invoice, 'whatever');            // also fine, as far as PHP is concerned

The parameter says string, because there is no type that means “one of these three”. That is the gap enums close.

The enum

enum InvoiceStatus: string
{
    case Draft = 'draft';
    case Pending = 'pending';
    case Paid = 'paid';
    case Cancelled = 'cancelled';
}

function markAs(Invoice $i, InvoiceStatus $status) { ... }

markAs($invoice, InvoiceStatus::Paid);   // fine
markAs($invoice, 'paid');                // TypeError, at the call

The type is now the constraint. A function that accepts InvoiceStatus cannot be handed anything else, so the validation that used to live inside every function does not need to be written at all.

Each case is a singleton object, which means === compares identity and always behaves:

$a = InvoiceStatus::from('paid');
$b = InvoiceStatus::Paid;
var_dump($a === $b);     // true — same object, not just equal
Magic strings, class constants and enums compared on what each prevents.
Constants catch the typo; only the enum makes the invalid value unrepresentable

Backed or pure

enum Direction { case Up; case Down; }                    // pure
enum InvoiceStatus: string { case Paid = 'paid'; }        // backed

A backed enum has a scalar value, which is what you need the moment the value is stored in a database, sent in JSON, or read from a form. A pure enum has no value and exists only in memory.

In practice almost everything in a web application is backed, because almost everything crosses a boundary. Pure enums are right for something genuinely internal — a sort direction passed between two methods, a state in an algorithm — where giving it a string value would just invite someone to persist it.

Backing type is string or int. Prefer string for anything stored: an int-backed status makes the database column unreadable and reordering the cases silently changes the meaning of existing rows.

from and tryFrom

InvoiceStatus::from('paid');        // the case
InvoiceStatus::from('nonsense');    // ValueError, uncaught 500

InvoiceStatus::tryFrom('nonsense'); // null
InvoiceStatus::tryFrom($input) ?? InvoiceStatus::Draft;

The rule that keeps this tidy: from() for data you control — your own database, your own config — where an unknown value is genuinely a bug and should be loud. tryFrom() for anything that came from outside, where an unknown value is Tuesday.

cases() returns them all, which is where the repetition starts disappearing:

// the <select> options, the validation rule, and the docs
// all derived from one declaration
foreach (InvoiceStatus::cases() as $case) {
    echo "<option value='{$case->value}'>{$case->label()}</option>";
}

Methods, and why this is the real upgrade

This is the part that constants could never do. An enum is a class, so behaviour that varies by case lives with the case.

enum InvoiceStatus: string
{
    case Draft = 'draft';
    case Pending = 'pending';
    case Paid = 'paid';
    case Cancelled = 'cancelled';

    public function label(): string
    {
        return match ($this) {
            self::Draft     => 'Draft',
            self::Pending   => 'Awaiting payment',
            self::Paid      => 'Paid',
            self::Cancelled => 'Cancelled',
        };
    }

    public function colour(): string
    {
        return match ($this) {
            self::Draft     => 'grey',
            self::Pending   => 'amber',
            self::Paid      => 'green',
            self::Cancelled => 'red',
        };
    }

    public function isEditable(): bool
    {
        return in_array($this, [self::Draft, self::Pending], true);
    }

    public function canTransitionTo(self $next): bool
    {
        return match ($this) {
            self::Draft     => in_array($next, [self::Pending, self::Cancelled], true),
            self::Pending   => in_array($next, [self::Paid, self::Cancelled], true),
            self::Paid, self::Cancelled => false,
        };
    }
}

Those four methods replace four scattered switch statements — one in a Blade template, one in a controller, one in a policy, one in a job — that each had to be found and updated when a case was added.

And match is exhaustive: it throws UnhandledMatchError when no arm matches. Add a fifth case to the enum and every match ($this) without a default fails immediately, pointing at the method that needs updating. That is the single biggest practical benefit of the whole feature — the compiler-like behaviour of being told where to go.

Do not add a default arm to a match over your own enum. The missing-case error is the feature.

Why match over an enum should not have a default arm.
The missing-case error is the feature — a default arm throws it away

In Laravel

class Invoice extends Model
{
    protected $casts = [
        'status' => InvoiceStatus::class,
    ];
}

That one line does more than it looks. Reading gives you a case, not a string; writing accepts a case and stores its value; and the query builder accepts cases too.

$invoice->status;                            // InvoiceStatus::Paid
$invoice->status->label();                   // 'Paid'
$invoice->update(['status' => InvoiceStatus::Paid]);

Invoice::where('status', InvoiceStatus::Paid)->get();

Validation gets the same treatment, and it means the form can never accept a value the enum does not define:

'status' => ['required', Rule::enum(InvoiceStatus::class)],

Route binding works too, with tryFrom semantics — an unmatched segment gives a 404 rather than an exception:

Route::get('/invoices/{status}', function (InvoiceStatus $status) { ... });

Blade then reads the way you would hope:

<span class="badge badge--{{ $invoice->status->colour() }}">
    {{ $invoice->status->label() }}
</span>

Interfaces, and the switch that finally disappears

Enums can implement interfaces, which is how you make unrelated enums interchangeable where it matters.

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

enum InvoiceStatus: string implements HasLabel { ... }
enum PaymentMethod: string implements HasLabel { ... }

function options(string $enum): array
{
    return collect($enum::cases())
        ->mapWithKeys(fn (HasLabel $c) => [$c->value => $c->label()])
        ->all();
}

One helper builds the option list for every dropdown in the application, and adding a new enum means implementing one method rather than writing another mapping array.

Enums can also have constants and use traits, but they cannot have properties — an enum case has no state beyond its identity and its backing value. That restriction is the point: two references to InvoiceStatus::Paid are always the same object, which is what makes === safe everywhere.

Migrating a codebase that is already full of strings

The realistic path, in the order that keeps the app running throughout.

One. Write the enum with exactly the values already in the column. Check first, because the real data always has surprises:

SELECT status, COUNT(*) FROM invoices GROUP BY status;

An empty string, a NULL, and a capitalised variant are the usual three. Decide what each becomes before writing the enum, and clean them with an update statement — not by adding cases for them.

Two. Add the cast. Reads now return cases, and any comparison against a raw string starts failing loudly, which is exactly the list of places you need to change.

Three. Work outward from those failures. Type-hint the enum in method signatures as you go; each one you add turns a class of runtime bug into a TypeError at the call site.

Four. Move the scattered switch statements into methods on the enum, one at a time. Do not do this in the same commit as step three.

The column itself does not need to change. A varchar holding enum values is fine, and converting to a native MySQL ENUM type trades a small storage saving for a migration every time you add a case.

Steps to migrate an existing string column to an enum.
The column stays a varchar — converting to MySQL ENUM buys a migration per case

Serialisation, the one to check

json_encode on a backed enum gives its value, which is usually what an API wants:

json_encode(['status' => InvoiceStatus::Paid]);   // {"status":"paid"}

A pure enum is not JSON-serialisable at all and throws — another reason to back anything that leaves the process.

Two more places worth a look before you ship. Queued jobs serialise their payload, and an enum property serialises fine, but an old job already in the queue was serialised against the old code — deploying a rename of a case while jobs are pending means those jobs fail on wake. And Rule::enum() validates the backing value, so an API accepting "Paid" before will now reject it; if external clients send the old casing, normalise in prepareForValidation rather than adding a case for it.

Static methods, and the collections an enum can build itself

Once the enum owns the set, the queries and groupings built from that set belong on it too.

enum InvoiceStatus: string
{
    // ...cases

    /** @return self[] */
    public static function open(): array
    {
        return [self::Draft, self::Pending];
    }

    /** @return self[] */
    public static function closed(): array
    {
        return array_values(array_diff(self::cases(), self::open()));
    }

    public static function values(): array
    {
        return array_column(self::cases(), 'value');
    }
}

values() is the one you will reach for constantly — it gives the plain strings for a whereIn, a migration default, or an old validation rule you have not converted yet. array_column works on an array of enums because the backing value is exposed as a readonly property.

Invoice::whereIn('status', InvoiceStatus::open())->count();

Defining closed() as the complement of open() rather than listing it out is a small thing that matters later: add a case and it lands in the right bucket automatically, instead of being silently absent from a list nobody remembered to update.

Testing an enum

Most enum methods do not need tests — label() returning a string is not where bugs live. Two things do.

public function test_every_case_has_a_label_and_a_colour(): void
{
    foreach (InvoiceStatus::cases() as $case) {
        $this->assertNotEmpty($case->label());
        $this->assertNotEmpty($case->colour());
    }
}

public function test_a_paid_invoice_cannot_go_back_to_draft(): void
{
    $this->assertFalse(InvoiceStatus::Paid->canTransitionTo(InvoiceStatus::Draft));
    $this->assertTrue(InvoiceStatus::Pending->canTransitionTo(InvoiceStatus::Paid));
}

The first is a loop over cases(), and it is the pattern worth adopting generally: any test that iterates cases() automatically covers cases added in future. It catches the method somebody extended with a new case but forgot to give a colour — where match would have caught it anyway, but only when that code path ran.

The second is the transition table, which is real business logic and deserves real tests. If the enum encodes a state machine, the tests for that state machine belong on the enum rather than scattered through the controller tests that happen to exercise it.

Two things enums cannot do

Worth knowing before you plan around them.

They cannot be extended. No enum inherits from another, and no class extends an enum. If two enums share behaviour, a trait or an interface is the answer — there is no base-enum pattern.

The set is fixed at deploy time. Cases are declared in code, so a status list the customer edits in an admin screen cannot be an enum. That is a database table, and trying to force it into an enum ends with a deploy required every time somebody adds a category.

The dividing line is whether the code branches on the value. If there is a match anywhere — different behaviour per case — the set is part of the program and belongs in an enum. If the code treats every value identically and only displays it, it is data and belongs in a table.

A useful middle case: an enum for the handful of statuses the code reasons about, plus a tags table for the open-ended labels users invent. Trying to make one mechanism serve both is what produces either a table full of values the code silently ignores, or an enum that needs a release to add a row.

Enums in the database, and the migration that does not need changing

A question that comes up immediately: should the column become a native MySQL ENUM now that the code has one?

Usually no. A native enum column stores an index rather than the string, which saves a few bytes per row and costs a schema migration every time a case is added — including on every environment, in the right order, before the code that uses it deploys. For a status column on a table of a few million rows, that trade is not worth making.

$table->string('status', 20)->default(InvoiceStatus::Draft->value)->index();

A varchar with an index behaves identically for every query you will write, and adding a case is a code deploy with no schema change at all. Using the enum’s own value for the default is worth doing — it keeps the migration honest if a case is ever renamed, because the rename becomes a compile error in the migration rather than a string that silently no longer matches.

The one case for a native enum column is when something outside your application writes to the table and you want the database itself to reject bad values. Then the constraint is doing real work, and a check constraint is often a better shape than an enum type anyway.

Casting a collection, and the query that surprises people

Two Eloquent behaviours worth knowing once the cast is in place.

// works — the builder unwraps a backed enum
Invoice::where('status', InvoiceStatus::Paid)->get();

// also works
Invoice::whereIn('status', [InvoiceStatus::Paid, InvoiceStatus::Pending])->get();

// does NOT do what it looks like
Invoice::where('status', '!=', InvoiceStatus::Paid)->get();   // misses NULL rows

The last one is not an enum problem — it is SQL’s NULL semantics — but the cast makes it easier to miss, because in PHP a nullable enum property reads as null and comparing it feels safe. In SQL, status != 'paid' excludes rows where status is NULL. If the column is nullable, the query needs orWhereNull, and the cleaner answer is usually to make the column non-nullable with a default of the draft case.

The other one is grouping. $invoices->groupBy('status') on a collection of models gives keys that are enum objects, which then do not match a string lookup in Blade. groupBy(fn ($i) => $i->status->value) is the fix, and it is the kind of thing that works in a test with one status and breaks on a page with four.

What it comes down to

The string bug at the top of this article is not really about typing. It is that there was no single place where the set of valid statuses was written down, so every part of the codebase had its own opinion and no two were checked against each other.

An enum is that place. The type system enforces it at every call site, match tells you where to go when the set changes, and the behaviour that varies by case sits next to the cases instead of in four templates and a job.