The orders table had a status column that had held free-form strings since 2015. A query for WHERE status = 'shipped' missed four hundred rows containing Shipped, and a query for both missed eleven containing SHIPPED. Nobody had written any of those deliberately; they had arrived from three different code paths over six years.
The symptom
mysql> SELECT status, COUNT(*) FROM orders GROUP BY status;
| status | COUNT(*) |
| pending | 8104 |
| paid | 188402 |
| shipped | 204118 |
| Shipped | 412 |
| SHIPPED | 11 |
| shippped | 3 |
| cancelled | 11204 |
| canceled | 88 |
| NULL | 4 |
-- eight intended values. nine actual ones, plus null.The four null rows are the worst of it — a column with no constraint, no default and no enforcement anywhere except in the code paths that happened to set it. The reporting had been quietly undercounting shipped orders by four hundred and twenty-six for years.
Why it happens
A string is the path of least resistance and has no domain. PHP had class constants, which give a name and do not give a type — a method typed string accepts a constant, a literal, a typo and the result of a form submission with equal enthusiasm.
Every attempt to enforce it before 8.1 was a convention plus a validation call plus a docblock, all three of which are optional at every call site.
The fix
Pure and backed, and which one a column needs
// pure: identified by itself. no scalar, nothing to store.
enum SortDirection
{
case Ascending;
case Descending;
}
// backed: a value, and therefore a column type
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
// the backing type is int or string. the values must be
// compile-time literals — no constants, no expressions.
A pure enum has a name and no value, and persisting the name is the mistake that recreates every problem a backed enum solves — a rename becomes a data migration nobody realises is needed. If the value leaves the process, it wanted to be backed.
String backing over integer is almost always right for something reaching a database: an integer column is smaller and produces rows nobody can read, and the cost of the string is bytes rather than correctness. The values are now a public contract, so renaming a case is free and changing its value is a migration.
from and tryFrom, at the right boundaries
// trusted boundary: your own database. a value here that
// is not a case means the data is corrupt — throw.
$status = OrderStatus::from($row['status']);
// untrusted boundary: an HTTP request. a client sending
// nonsense is normal — do not 500.
$status = OrderStatus::tryFrom($request->input('status'))
?? throw new InvalidStatus($request->input('status'));
// using from() everywhere turns a 422 into a 500.
// using tryFrom() everywhere converts corruption into a
// null that travels further before failing.
The two methods exist because the correct behaviour genuinely differs by caller, and choosing one for the whole codebase gets one of the two cases wrong. Writing the rule down next to the enum — from inside, tryFrom at the edge — is what makes it survive review.
Behaviour on the enum, replacing a match in six places
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function isFinal(): bool
{
return match ($this) {
self::Shipped, self::Cancelled => true,
self::Pending, self::Paid => false,
};
}
public function label(): string
{
return match ($this) {
self::Pending => __('Awaiting payment'),
self::Paid => __('Paid'),
self::Shipped => __('Dispatched'),
self::Cancelled => __('Cancelled'),
};
}
/** @return list<self> */
public static function open(): array
{
return array_values(array_filter(
self::cases(),
static fn (self $c): bool => ! $c->isFinal(),
));
}
}
The match with no default is the whole benefit: adding a case produces an UnhandledMatchError at every place that has to change, at the moment the new case first occurs. A default arm silently returns a wrong label instead, which is the behaviour the previous class constants had.
The same match had existed in a controller, two Blade templates, a mailer and an exporter, and adding Cancelled in 2019 had updated four of the five. That is the bug this removes structurally rather than by diligence.
What an enum cannot do
no inheritance enum X extends Y → fatal.
an enum is implicitly final, and
nothing may extend it.
no state public $foo inside an enum → fatal.
a case is a singleton; mutable state
would make two references disagree.
not instantiable new OrderStatus() → fatal.
constants ARE and they are implicitly final.
allowed const DEFAULT = self::Pending;
interfaces ARE implements HasLabel — this is the
allowed escape hatch for polymorphism.
traits ARE allowed as long as they declare no properties.The prohibition on properties is the one people hit first and it is deliberate rather than an omission — an enum case is a singleton, so a mutable property would be shared by every reference to that case. Anything that feels like it needs state is a value object holding an enum rather than an enum.
The ORM boundary
final class Order extends Model
{
protected $casts = [
'status' => OrderStatus::class, // native, from Laravel 9
];
}
// on Laravel 8 in December 2021, a custom cast:
final class AsOrderStatus implements CastsAttributes
{
public function get($model, $key, $value, array $attributes): ?OrderStatus
{
return $value === null ? null : OrderStatus::from($value);
}
public function set($model, $key, $value, array $attributes): array
{
return [$key => $value?->value];
}
}
Returning the raw backing value from set is what keeps the dirty check working — it compares raw attributes, so returning the enum object marks the model dirty on every assignment even when nothing changed. from rather than tryFrom in get is the deliberate choice: a value in your own column that is not a case is corruption and should be loud.
The migration, which is the actual work
-- 1. normalise what exists, before anything can enforce it
UPDATE orders SET status = LOWER(TRIM(status));
UPDATE orders SET status = 'shipped' WHERE status = 'shippped';
UPDATE orders SET status = 'cancelled' WHERE status = 'canceled';
-- 2. the four nulls, which need a person to decide
SELECT id, placed_at, total_cents FROM orders WHERE status IS NULL;
-- 3. and only then, a constraint the database enforces
ALTER TABLE orders
MODIFY status VARCHAR(32) NOT NULL,
ADD CONSTRAINT chk_status CHECK (status IN
('pending','paid','shipped','cancelled'));
The database constraint is the half that the enum does not give you: an enum stops your PHP writing a bad value and does nothing about a migration, a bulk update or a person with a database client. MySQL 8.0 enforces CHECK constraints, which earlier versions parsed and ignored.
The four null rows took a conversation with the operations team and turned out to be three test orders from 2016 and one genuine order that had been abandoned mid-checkout. That is the shape of every one of these migrations — the data cleanup is not mechanical and is where the time goes.
// and the guard that stops it recurring, before the
// constraint exists: fail the build on an unknown value
public function testEveryStoredStatusIsAKnownCase(): void
{
$stored = DB::table('orders')->distinct()->pluck('status');
$known = array_column(OrderStatus::cases(), 'value');
$this->assertEmpty(array_diff($stored->all(), $known));
}
Exhaustiveness, and what the analyser can prove
$ vendor/bin/phpstan analyse --level=8
src/Order/OrderStatus.php
:24 Match expression does not handle remaining case
OrderStatus::Refunded
# adding a case produces a static error at every match
# with no default — BEFORE the code runs.
# what it cannot prove:
# a match with a default arm (it is exhaustive by
# construction, and silently wrong)
# a value arriving from outside the type system
# a database row written by something elseThe analyser proving exhaustiveness statically is what makes this a design tool rather than a runtime check, and it only works when there is no default arm. Adding default to silence a level-8 message throws away the entire benefit, which is worth a note in the review checklist.
Verifying it worked
mysql> SELECT status, COUNT(*) FROM orders GROUP BY status;
| pending | 8104 |
| paid | 188402 |
| shipped | 204541 | -- +426, and correct
| cancelled | 11292 |
mysql> UPDATE orders SET status = 'Shipped' WHERE id = 1;
ERROR 3819 (HY000): Check constraint 'chk_status' is violated.
$ vendor/bin/phpstan analyse
[OK] No errors
$ vendor/bin/phpunit --filter StatusIsAKnownCase
OK (1 test, 1 assertion)The database refusing a bad value is the assertion that the constraint is real, and it is worth executing rather than trusting — a CHECK constraint on MySQL 5.7 is accepted, stored and ignored, which is the version of this that looks identical and does nothing.
The shipped count going up by four hundred and twenty-six is the outcome that mattered to somebody outside engineering, and it needed explaining: the reports had been wrong, they are now right, and the historical figures in last year’s deck do not match.
What this costs
A minimum version bump to 8.1, which for a library is a one-way door and for an application is a decision to make once. Enums cannot be conditionally adopted — a file containing one is a parse error on 8.0 — so this is all or nothing per codebase.
The enum values are also a public contract now in a way class constants were not, because they are serialised into the database, into API responses and into queued job payloads. A job enqueued before a value changes and processed after it will fail to deserialise, which is a deploy-ordering problem nobody thinks about until it happens.
And the honest limitation: an enum constrains PHP and constrains nothing else. The database constraint, the API schema and the queue payload each need their own enforcement, and keeping four definitions of the same closed set in step is the work this creates. Generating the other three from the enum is possible and is another build step; writing them by hand is what most projects do and is how they drift.