Every business app has columns like status, type and work_mode. In a recruitment system I've been building on Laravel, an offer alone moves through eleven states: draft, pending approval, rejected internally, approved, sent, viewed, accepted, declined, expired, cancelled and superseded. Those strings end up everywhere: migrations, validation rules, queries, API responses, the dropdowns in the front end, the colour of a badge.
The traditional approach of class constants and string literals relies on discipline rather than the type system. Since PHP 8.1, backed enums solve this properly. This post covers how I use them in a Laravel API so that each set of values is defined once and everything else (the database, validation, the API and the TypeScript front end) is derived from that definition.
The enum
A backed enum is a closed set of cases, each with a scalar value:
<?php
namespace App\Enums;
use App\Enums\Concerns\HasOptions;
enum OfferStatus: string
{
use HasOptions;
case Draft = 'DRAFT';
case PendingApproval = 'PENDING_APPROVAL';
case RejectedInternally = 'REJECTED_INTERNALLY';
case Approved = 'APPROVED';
case Sent = 'SENT';
case Viewed = 'VIEWED';
case Accepted = 'ACCEPTED';
case Declined = 'DECLINED';
case Expired = 'EXPIRED';
case Cancelled = 'CANCELLED';
case Superseded = 'SUPERSEDED';
public function label(): string
{
return match ($this) {
self::Draft => 'Draft',
self::PendingApproval => 'Pending Approval',
self::RejectedInternally => 'Rejected Internally',
self::Approved => 'Approved',
self::Sent => 'Sent',
self::Viewed => 'Viewed',
self::Accepted => 'Accepted',
self::Declined => 'Declined',
self::Expired => 'Expired',
self::Cancelled => 'Cancelled',
self::Superseded => 'Superseded',
};
}
}
Some conventions I've settled on:
- The value is the storage format; the case name is for code. Values are
SCREAMING_SNAKEstrings because they're stable, readable in raw SQL, and never shown to users. Case names arePascalCasebecause they read nicely in PHP (OfferStatus::PendingApproval). - Labels live on the enum.
label()uses amatchwith nodefaultarm. If someone adds a case and forgets the label, PHP throws anUnhandledMatchErrorthe first time it's hit, and static analysers like PHPStan flag it before that. Adefault => ucfirst(...)would silently paper over it. - String-backed, not int-backed.
status = 4in a database row tells you nothing.status = 'SENT'does.
A trait for "options"
Front ends constantly need value/label pairs for selects, filters and badges. Rather than repeat that in every enum, a tiny trait does it:
<?php
namespace App\Enums\Concerns;
trait HasOptions
{
/**
* @return array<int, array{value: string, label: string}>
*/
public static function options(): array
{
return array_map(
fn (self $case) => ['value' => $case->value, 'label' => $case->label()],
self::cases()
);
}
}
self inside a trait refers to the class using it, so OfferStatus::options() returns:
[
{ "value": "DRAFT", "label": "Draft" },
{ "value": "PENDING_APPROVAL", "label": "Pending Approval" }
]
The database: a string column, not a DB enum
Schema::create('offers', function (Blueprint $table) {
$table->id();
$table->foreignId('application_id')->constrained()->cascadeOnDelete();
$table->string('status', 32)->default(OfferStatus::Draft->value)->index();
$table->timestamps();
});
I deliberately avoid $table->enum(...). A native MySQL/MariaDB ENUM column means every new case needs a schema migration, and changing the column's allowed list on a big table can be slow. A VARCHAR plus validation at the application boundary gives the same guarantee for data entering through the app, and adding a case becomes a one-line code change. If you want belt and braces, add a CHECK constraint, but generate it from OfferStatus::cases() so it can't drift.
Eloquent: cast it once
class Offer extends Model
{
protected function casts(): array
{
return [
'status' => OfferStatus::class,
];
}
}
(On Laravel 10 and earlier, use the $casts property instead of the method.)
From then on, $offer->status is an OfferStatus instance, not a string. Assigning a case stores its value, and Laravel's query builder understands enums too:
$offer->status = OfferStatus::Sent;
$offer->save();
$open = Offer::query()
->whereIn('status', [OfferStatus::Sent, OfferStatus::Viewed])
->get();
if ($offer->status === OfferStatus::Accepted) {
// enum cases are singletons, so strict comparison just works
}
Reading a row with an unknown value throws a ValueError. That's a feature: it means someone wrote to the column without going through the app, and you want to know.
Validation: Rule::enum
Form Requests validate against the enum directly, so there's no list of allowed strings to keep in sync:
use App\Enums\RejectionReason;
use Illuminate\Validation\Rule;
public function rules(): array
{
return [
'stage_id' => ['required', 'integer', 'exists:pipeline_stages,id'],
'reason_code' => ['nullable', Rule::enum(RejectionReason::class)],
'reason_text' => ['nullable', 'string', 'max:1000'],
];
}
On recent Laravel versions the rule can be narrowed further, which is handy when an endpoint only accepts some of the cases:
'status' => [
'required',
Rule::enum(OfferStatus::class)->only([OfferStatus::Accepted, OfferStatus::Declined]),
],
In the controller, $request->enum('status', OfferStatus::class) gives you the case directly (or null), instead of a string you have to convert yourself.
State transitions belong on the enum too
Statuses usually aren't free-form. An offer can go from approved to sent, but never from accepted back to draft. That rule is about the values themselves, so it fits on the enum:
/** @return list<self> */
public function allowedTransitions(): array
{
return match ($this) {
self::Draft => [self::PendingApproval, self::Cancelled],
self::PendingApproval => [self::Approved, self::RejectedInternally, self::Cancelled],
self::RejectedInternally => [self::Draft, self::Cancelled],
self::Approved => [self::Sent, self::Cancelled],
self::Sent => [self::Viewed, self::Accepted, self::Declined, self::Expired, self::Cancelled, self::Superseded],
self::Viewed => [self::Accepted, self::Declined, self::Expired, self::Cancelled, self::Superseded],
self::Accepted,
self::Declined,
self::Expired,
self::Cancelled,
self::Superseded => [],
};
}
public function canTransitionTo(self $next): bool
{
return in_array($next, $this->allowedTransitions(), true);
}
public function isTerminal(): bool
{
return $this->allowedTransitions() === [];
}
Again, there's no default arm, so a new case can't slip through without someone deciding where it may go. The service that changes an offer's status can then guard every change in one line:
if (! $offer->status->canTransitionTo($next)) {
throw ValidationException::withMessages([
'status' => "An offer that is {$offer->status->label()} can't be moved to {$next->label()}.",
]);
}
The API: values and labels
API Resources return both, so clients never have to hard-code labels:
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => [
'value' => $this->status->value,
'label' => $this->status->label(),
],
'can_be_sent' => $this->status->canTransitionTo(OfferStatus::Sent),
];
}
For dropdowns and filters, one "lookups" endpoint serves every option list the front end needs:
Route::get('/lookups', fn () => [
'offer_statuses' => OfferStatus::options(),
'work_modes' => WorkMode::options(),
'employment_types' => EmploymentType::options(),
]);
The front end caches that response once per session. Adding a case in PHP makes it appear in every dropdown without touching any JavaScript.
TypeScript: generate the union type
The front end still wants compile-time types for the values, so it can write status === 'ACCEPTED' safely. A closure command in routes/console.php generates them:
use Illuminate\Support\Facades\Artisan;
Artisan::command('enums:typescript', function () {
$enums = [
\App\Enums\OfferStatus::class,
\App\Enums\WorkMode::class,
\App\Enums\EmploymentType::class,
];
$out = "// Generated by `php artisan enums:typescript`. Do not edit.\n\n";
foreach ($enums as $enum) {
$name = class_basename($enum);
$values = array_map(fn ($case) => "'{$case->value}'", $enum::cases());
$out .= "export type {$name} = " . implode(' | ', $values) . ";\n";
}
file_put_contents(resource_path('js/types/enums.ts'), $out);
$this->info('Wrote resources/js/types/enums.ts');
})->purpose('Generate TypeScript types from PHP enums');
Output:
// Generated by `php artisan enums:typescript`. Do not edit.
export type OfferStatus = 'DRAFT' | 'PENDING_APPROVAL' | 'REJECTED_INTERNALLY' | 'APPROVED' | 'SENT' | 'VIEWED' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED' | 'CANCELLED' | 'SUPERSEDED';
Run it in CI and fail the build if the generated file differs from the committed one (git diff --exit-code resources/js/types/enums.ts). Then a PHP-side change that isn't reflected in TypeScript can't be merged.
Testing every case
Since cases() is a plain array, Pest datasets make "every case must have X" a one-liner:
use App\Enums\OfferStatus;
it('has a label for every case', function (OfferStatus $status) {
expect($status->label())->toBeString()->not->toBeEmpty();
})->with(OfferStatus::cases());
it('only lets terminal statuses have no way out', function (OfferStatus $status) {
expect($status->isTerminal())->toBe(in_array($status, [
OfferStatus::Accepted, OfferStatus::Declined, OfferStatus::Expired,
OfferStatus::Cancelled, OfferStatus::Superseded,
], true));
})->with(OfferStatus::cases());
Summary
For each set of values, the enum is now the single definition that everything else derives from:
| Concern | Derived from the enum via |
|---|---|
| Database | string column + ->default(Enum::Case->value) |
| Model | casts() → Enum::class |
| Validation | Rule::enum(Enum::class) |
| Labels | label() with an exhaustive match |
| Workflow rules | allowedTransitions() / canTransitionTo() |
| API | options() + value/label pairs in resources |
| Front end | generated TypeScript union types |
Adding a twelfth offer status is now a single-file change, and PHPStan, the test suite and the CI diff check will each complain if anything else needed to change too.
