Ahmed Fathallah
WritingAboutContact
Sign in
WritingAboutContact
Sign inSign up

Ahmed Fathallah

Engineering notes on type-safe full-stack development, modern back ends and automated delivery.

WritingAboutContactSitemap

© 2026 Ahmed Fathallah

    PHP Backed Enums as the Single Source of Truth in a Laravel API

    October 11, 2026·6 min read

    PHP
    TypeScript
    Laravel
    PHP Backed Enums as the Single Source of Truth in a Laravel API

    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_SNAKE strings because they're stable, readable in raw SQL, and never shown to users. Case names are PascalCase because they read nicely in PHP (OfferStatus::PendingApproval).
    • Labels live on the enum. label() uses a match with no default arm. If someone adds a case and forgets the label, PHP throws an UnhandledMatchError the first time it's hit, and static analysers like PHPStan flag it before that. A default => ucfirst(...) would silently paper over it.
    • String-backed, not int-backed. status = 4 in 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:

    ConcernDerived from the enum via
    Databasestring column + ->default(Enum::Case->value)
    Modelcasts() → Enum::class
    ValidationRule::enum(Enum::class)
    Labelslabel() with an exhaustive match
    Workflow rulesallowedTransitions() / canTransitionTo()
    APIoptions() + value/label pairs in resources
    Front endgenerated 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.

    Comments

    Related Articles

    Validating Next.js Environment Variables with Zod at Build Time and Runtime

    Validating Next.js Environment Variables with Zod at Build Time and Runtime

    A build can pass and health checks stay green while production ships a placeholder value. How to validate env vars with Zod in a containerised Next.js app, why NEXT_PUBLIC_ values are frozen at build time, and where to fail fast.

    Docker
    Next.js
    TypeScript
    Zod
    Laravel-Style Authorization Policies for tRPC Procedures

    Laravel-Style Authorization Policies for tRPC Procedures

    Bringing Laravel's policy pattern to a Next.js + tRPC + Prisma app: permission-based policy classes, one authorize() call per procedure, compile-time checked action names, and Prisma scopes for lists.

    Next.js
    TypeScript
    tRPC
    Prisma
    Fitness Your Journey to a Healthier You

    Fitness Your Journey to a Healthier You

    Fitness, the state of being physically healthy and strong, is essential for overall well-being. It encompasses a wide range of activities, from cardiovascular exercise to strength training and flexibility exercises.

    PHP
    HTML