Himanshu SharmaSoftware engineer
Let’s talk

PHP · Lesson 48 of 54

Enums

Model a fixed set of options with PHP 8.1 enums, use pure and backed enums, convert from database values with from and tryFrom, and add methods and match.

12 minObject-oriented PHPUpdated 22 October 2026

Awaiting technical review. This lesson is readable but not yet listed in search engines.

By the end of this lessonReplace magic strings with enums, store backed enum values in a database and add behaviour to enum cases.

The trouble with magic strings

An order has a status: pending, paid, shipped, delivered or cancelled. A natural first approach stores the status as a string:

php
<?php
$order['status'] = 'shipped';
if ($order['status'] === 'shiped') { /* never true: a typo */ }
function markAs(string $status) { /* accepts 'banana' just as happily */ }

Nothing stops a typo, and every function that takes a status has to check it is one of the allowed values. Class constants from Static members and class constants help a little, but the parameter type is still just string.

PHP 8.1 added enumerations (enums): a type with a fixed list of possible values.

A pure enum

An enum lists its cases. Each case is a value of the enum's type, and nothing else is:

php
<?php
enum Size
{
    case Small;
    case Medium;
    case Large;
}

function price(Size $size): int
{
    return match ($size) {
        Size::Small => 99,
        Size::Medium => 149,
        Size::Large => 199,
    };
}

echo price(Size::Medium) . "\n";
echo Size::Large->name . "\n";
var_dump(Size::Small === Size::Small);
var_dump(Size::Small instanceof Size);
result
149
Large
bool(true)
bool(true)

price() accepts only a Size. Passing the string 'medium' would be a TypeError. Every case has a name property, and cases can be compared with === because each case exists exactly once. match and enums are a natural pair, as switch and match hinted: if a new case is added later and a match does not handle it, PHP throws an UnhandledMatchError that you notice immediately.

Backed enums

To store a status in a database or send it in JSON, you need a plain value. A backed enum gives every case a string or int value:

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

$status = OrderStatus::Shipped;
echo $status->name . " is stored as '" . $status->value . "'\n";

$fromDatabase = OrderStatus::from('paid');
var_dump($fromDatabase === OrderStatus::Paid);

var_dump(OrderStatus::tryFrom('lost'));
echo count(OrderStatus::cases()) . " statuses\n";
echo json_encode(['status' => OrderStatus::Delivered]) . "\n";
result
Shipped is stored as 'shipped'
bool(true)
NULL
5 statuses
{"status":"delivered"}
  • ->value is the backing value you save.
  • from() turns a stored value back into a case and throws a ValueError for an unknown value; tryFrom() returns null instead, which is the right choice for user input.
  • cases() lists every case, handy for building a <select> menu.
  • json_encode() writes the backing value automatically.

In MySQL you would store the value in a VARCHAR or ENUM column; Laravel can cast a model attribute to an enum for you.

Methods on enums

Enums can have methods, which keeps everything about a status in one place instead of scattered if statements:

php
<?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 => 'Waiting for payment',
            self::Paid => 'Preparing your order',
            self::Shipped => 'On its way',
            self::Delivered => 'Delivered',
            self::Cancelled => 'Cancelled',
        };
    }

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

    /** @return list<self> */
    public static function open(): array
    {
        return array_values(array_filter(self::cases(), fn(self $s) => !in_array($s, [self::Delivered, self::Cancelled], true)));
    }
}

foreach (OrderStatus::cases() as $status) {
    printf("%-10s %-22s cancel: %s\n", $status->value, $status->label(), $status->canCancel() ? 'yes' : 'no');
}
echo "Open: " . implode(', ', array_map(fn($s) => $s->name, OrderStatus::open())) . "\n";
result
pending    Waiting for payment    cancel: yes
paid       Preparing your order   cancel: yes
shipped    On its way             cancel: no
delivered  Delivered              cancel: no
cancelled  Cancelled              cancel: no
Open: Pending, Paid, Shipped

Inside the enum, $this is the current case and self:: refers to the enum. Enums can also define constants and implement interfaces, but they cannot have properties that change: each case is fixed.

Enums with form input

Combine tryFrom() with the validation habits from Validating and escaping form input:

php
<?php
enum OrderStatus: string
{
    case Pending = 'pending';
    case Shipped = 'shipped';
}

foreach (['shipped', 'SHIPPED', 'teleported'] as $input) {
    $status = OrderStatus::tryFrom(strtolower(trim($input)));
    echo $input . " => " . ($status?->name ?? 'invalid status') . "\n";
}
result
shipped => Shipped
SHIPPED => Shipped
teleported => invalid status

The nullsafe operator ?-> reads name only when a case was found.

Common mistakes

  • Using from() on user input, which throws for any unexpected value. Use tryFrom() and check for null.
  • Comparing a case with its string value: OrderStatus::Paid === 'paid' is false. Compare ->value, or convert the string first.
  • Leaving out cases in a match without a default, then adding a case later. That is a feature: the error shows you where to update.
  • Using enums for lists that change often, such as product categories that editors create. Those belong in a database table.

Exercise

Create a backed enum Plan: string with cases Free = 'free', Pro = 'pro' and Team = 'team', and methods monthlyPrice(): int (0, 499, 1999) and maxProjects(): ?int (3, 50, unlimited as null). Print a line for every plan like pro: 499/month, 50 projects.

Show one possible answer
php
<?php
enum Plan: string
{
    case Free = 'free';
    case Pro = 'pro';
    case Team = 'team';

    public function monthlyPrice(): int
    {
        return match ($this) {
            self::Free => 0,
            self::Pro => 499,
            self::Team => 1999,
        };
    }

    public function maxProjects(): ?int
    {
        return match ($this) {
            self::Free => 3,
            self::Pro => 50,
            self::Team => null,
        };
    }
}

foreach (Plan::cases() as $plan) {
    $projects = $plan->maxProjects() ?? 'unlimited';
    echo "{$plan->value}: {$plan->monthlyPrice()}/month, $projects projects\n";
}
result
free: 0/month, 3 projects
pro: 499/month, 50 projects
team: 1999/month, unlimited projects

Key points

  • An enum defines a type with a fixed list of possible values, called cases: enum Status { case Pending; case Paid; }.
  • A backed enum gives each case a string or int value for storage: case Paid = 'paid';
  • Status::from('paid') converts a stored value back to a case; tryFrom() returns null instead of throwing for unknown values.
  • Enums can have methods and constants and implement interfaces; match over an enum keeps every case handled.

Check yourself

1. What problem do enums solve compared with plain strings like 'paid'?
2. What does OrderStatus::tryFrom('lost') return when there is no such case?
3. How do you read the stored value of a backed enum case?
4. Is OrderStatus::Paid === OrderStatus::Paid true?