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. Not1. 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
stringcolumn in the database, not MySQLenum. - 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.