Skip to content
OpenCloseEsc
Blog

5 min read

How I use string enums in Laravel

A typo between 'complete' and 'completed' lived in production for two weeks. Since then every status is a string enum: cast on the model, validated with Rule::enum, with its labels, colors and rules inside it.

For years I wrote order statuses like this:

$order->status = 'pending';
 
if ($order->status === 'completed') { ... }

It worked. Until one day I wrote 'complete' in one place and 'completed' in another. The bug lived in production for two weeks.

Now I use enums. Here is how, and why I always pick string enums over int enums.

Why string, not int

PHP gives you two kinds of backed enums:

enum OrderStatus: int    { case Pending = 1; }
enum OrderStatus: string { case Pending = 'pending'; }

Int enums save a few bytes. That's the only plus I see.

String enums win on everything else:

  • Open the database, you see pending. Not 1. You don't need the code to understand the data.
  • Your API returns "status": "pending". Frontend developers say thank you.
  • Logs are readable.
  • If you delete a case, the old rows still make sense.

Database size has never been my bottleneck. Readability saves me time every week.

The basic enum

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

I put them in app/Enums. One enum, one file. When the project grows I move them next to their domain, but at the start this folder is enough.

Cast it on the model

class Order extends Model
{
    protected function casts(): array
    {
        return [
            'status' => OrderStatus::class,
        ];
    }
}

Now $order->status is an enum object, not a string:

$order->status === OrderStatus::Paid;  // true or false, no typos possible
$order->status->value;                 // 'paid'

And when you save, Laravel writes 'paid' to the database for you.

The migration

Don't use a MySQL enum column. Every time you add a case you need a migration that changes the column, and on a big table that can lock it for minutes.

Use a plain string:

$table->string('status', 32)->default(OrderStatus::Pending->value);
$table->index('status');

The enum class is your validation. The database just stores text.

Validate incoming values

use Illuminate\Validation\Rule;
 
public function rules(): array
{
    return [
        'status' => ['required', Rule::enum(OrderStatus::class)],
    ];
}

If someone sends "status": "done", the request fails before it touches your code.

Labels for humans

The database says in_review. The UI should say "In review". Put that in the enum:

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

If you use match and forget a case, PHP throws an error. That's a feature. It tells you the moment you add a new case and forget to handle it.

I also add a color for badges in the admin:

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

In Blade:

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

Business rules inside the enum

This is where enums become really useful. Logic about a status lives with the status:

public function canBeCancelled(): bool
{
    return in_array($this, [self::Pending, self::Paid]);
}
 
public function isFinal(): bool
{
    return in_array($this, [self::Delivered, self::Cancelled]);
}

Then in your action class:

if (! $order->status->canBeCancelled()) {
    throw new OrderCannotBeCancelled($order);
}

Before, this check was an if with three string compares, copied in four places. Now it's one method, one place.

Allowed transitions

A small step further. Which status can move to which:

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

Now a shipped order can't go back to pending by mistake. The rule is in one place and it's easy to read as a table.

Select options for forms

You need the list for a dropdown:

public static function options(): array
{
    return array_map(
        fn (self $case) => ['value' => $case->value, 'label' => $case->label()],
        self::cases()
    );
}
return response()->json(OrderStatus::options());

Works for Blade selects, for React, for anything.

Querying

Order::where('status', OrderStatus::Paid)->get();

Laravel converts the enum to its value for you. You can also use it in scopes:

public function scopeOpen($query)
{
    return $query->whereIn('status', [
        OrderStatus::Pending,
        OrderStatus::Paid,
        OrderStatus::Shipped,
    ]);
}

Things that bit me

tryFrom vs from. OrderStatus::from('xyz') throws. OrderStatus::tryFrom('xyz') returns null. Use tryFrom when the input comes from outside and you're not sure.

Enums in config and constants. You can't put an enum case in a const or in config/*.php. Use ->value there, and convert back with from() when you read it.

Comparing with strings. $order->status === 'paid' is always false now. It's an object. Write $order->status === OrderStatus::Paid or $order->status->value === 'paid'. This is the first thing that breaks when you add a cast to an old model.

Renaming a case. Renaming Paid to Payed in code is free. Changing the value 'paid' to something else means a data migration. Pick your values once and think a little before you do.

Old data. If the database has a value that is not in the enum, reading the model throws ValueError. Clean the data before you add the cast. One SELECT DISTINCT status FROM orders saves a lot of pain.

The short version

  • String enum, not int. Readable data is worth more than small columns.
  • Plain string column in the database, not MySQL enum.
  • Cast it on the model, validate with Rule::enum.
  • Put labels, colors, and rules inside the enum. That's what it's for.
  • Clean old data before adding the cast.

An enum is a small thing. But it moves a whole class of bugs from "found in production" to "found by the editor". That is a good trade.