Enums (the full enum contract)
Part of the Labrodev playbook. The law for this component lives in the always-on labrodev-enum guideline (musts, must-nots); the per-file checklist is rules/enums.md. This skill holds the craft: anatomy, canonical templates, and edge cases.
Enums represent constrained domain values: statuses, types, modes — any field with a finite allowed set and matching logic. When an enum crosses a boundary, the full enum contract applies (the law → labrodev-enum guideline). Its four parts map onto the sections below:
| # | Boundary | Contract |
|---|---|---|
| 1 | Enum class | Backed scalar enum, model-prefixed name, label(): string wrapping trans() |
| 2 | Data class (write side) | Typed enum property (implicit Spatie cast) + Rule::enum(EnumClass::class) |
| 3 | Model (persistence) | Enum cast declared in the casts() method |
| 4 | Frontend (Inertia props) | Resources emit value + *_label pairs; ViewModels build options via EnumMapper::keyValues() |
1) Enum anatomy — canonical template
Naming pattern: {Model}{Aspect} (e.g. BookingStatus, BookingChannel, ProductType), namespace Core\Domain\{Domain}\Enums. Full class/method naming rules → see the labrodev-naming skill.
<?php
declare(strict_types=1);
namespace Core\Domain\Booking\Enums;
enum BookingStatus: int
{
case Pending = 1;
case Confirmed = 2;
case Cancelled = 3;
public function label(): string
{
return match ($this) {
self::Pending => trans('Pending'),
self::Confirmed => trans('Confirmed'),
self::Cancelled => trans('Cancelled'),
};
}
public static function fromValue(int $value): ?self
{
return self::tryFrom($value);
}
}
Notes:
label()is THE presentation helper: always that name, always wrappingtrans(), always an exhaustivematchwith no default arm. Additional pure presentation helpers (color(),icon(),shortLabel()) follow the same constraints — pure, no side effects, no Actions/Services/Jobs.fromValue()is the optional static named constructor wrappingtryFrom()for domain lookups — returns?self, never throws.- Enums are not classes, so
finaldoes not apply; thedeclare(strict_types=1)header contract and the no-comments law still do → see the labrodev-core skill. trans()keys follow the readable-English-string style backed by Laravellang/*.json→ see the labrodev-inertia-react skill.
2) Data boundary (write side)
The enum contract at the validation boundary has two fixed parts inside the Data class — a typed property and a Rule::enum rule on the same raw input key. Surrounding Data class anatomy (rules(), attributes(), prepareForPipeline(), UUID casters) → see the labrodev-data skill.
<?php
declare(strict_types=1);
namespace Core\Domain\Booking\Data;
use Core\Domain\Booking\Enums\BookingStatus;
use Illuminate\Validation\Rule;
use Spatie\LaravelData\Data;
final class BookingData extends Data
{
public function __construct(
public BookingStatus $status,
) {
}
/**
* @return array<string, array<int, mixed>>
*/
public static function rules(): array
{
return [
'status' => ['required', Rule::enum(BookingStatus::class)],
];
}
}
The typed property lets Spatie Data cast the raw scalar implicitly; the rule is ALWAYS Rule::enum — never 'in:1,2,3', never a raw value array. Optional fields take the nullable variant on both parts: public ?BookingStatus $status = null with ['nullable', Rule::enum(BookingStatus::class)]. Validation targets the raw input key (status as a scalar); casting to the enum instance happens after validation.
3) Model boundary (persistence)
Any model field backed by an enum MUST be cast in the model's casts() method — never a $casts property. Full model anatomy (attributes, relations, $visible, observers) → see the labrodev-model skill.
// Core\Domain\Booking\Models\Booking
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'status' => BookingStatus::class,
];
}
After this, $booking->status is always a BookingStatus instance in PHP — Actions assign it as $booking->status = BookingStatus::Pending; (explicit assignment, no mass assignment → see the labrodev-model skill).
4) Frontend contract (Resources + ViewModels + React)
Resource: emit value + label pairs
Field grammar and allowlisting rules for Resources → see the labrodev-viewmodel-resource skill. The enum-specific rule:
Inside App\Layer\Dashboard\Booking\Resources\BookingResource::toArray():
'status' => $this->status->value,
'status_label' => $this->status->label(),
Both keys, always: value for logic, *_label for display. Never one without the other when the frontend renders the field. A nullable enum field emits the same pair through nullsafe calls ($this->status?->value, $this->status?->label()).
ViewModel: select/filter options via EnumMapper
ViewModel anatomy → see the labrodev-viewmodel-resource skill. The enum-specific rule — option maps come from EnumMapper::keyValues() (package labrodev/php-enum-mapper), never hardcoded arrays:
// App\Layer\Dashboard\Booking\ViewModels\BookingFormViewModel
use Core\Domain\Booking\Enums\BookingStatus;
use Labrodev\PhpEnumMapper\EnumMapper;
public function statusOptions(): array
{
return EnumMapper::keyValues(BookingStatus::cases(), 'label');
}
The frontend receives this as its { value, label } option set for selects and filters.
React: render label, use value for logic
Page structure and props typing → see the labrodev-inertia-react skill. Enum-specific rules:
- TypeScript literal unions mirror the enum values:
type BookingStatusValue = 1 | 2 | 3; - Display uses
booking.status_label; conditions and filters usebooking.status(the raw value). - Unknown values get an explicit fallback — show the raw value, never silently render nothing:
<Badge>{booking.status_label ?? String(booking.status)}</Badge>
- Never map value → text in React; the backend
label()is the single source of truth.
Edge cases
- Enum not backed by a model field (e.g. a mode submitted only in a Data class): parts 1, 2, and 4 of the contract still apply; part 3 (model cast) does not — there is no column.
- Enum used only internally in Core (never crosses a boundary): only part 1 applies;
label()remains optional until the enum is presented anywhere. - Backing type choice:
intfor ordered/stateful sets persisted as integers (BookingStatus),stringfor values whose stored form is itself meaningful (BookingChannel: string { case Web = 'web'; ... }). Pick per domain, then keep the database column type in sync. - Renaming or removing a case: it is a data migration concern — existing rows hold the old backing value. Migrate the column before removing the case;
tryFrom()/named constructors returnnullfor orphaned values, and the frontend fallback (raw value) makes them visible instead of crashing. - Adding a case: the exhaustive
matchinlabel()(nodefault) makes every unlabeled new case throw\UnhandledMatchError— that is intentional. Add the label and thelang/*.jsonentry in the same change. - Legacy zone: vendor/starter code under
app/Http,app/Models,app/Actions/Fortifyis exempt → see the labrodev-core skill.