webfox/laravel-backed-enums
Adds Laravel-friendly helpers to PHP backed enums: generate labels and option lists, validate enum values, and use them cleanly in forms, APIs, and application logic. A lightweight way to make backed enums more practical in Laravel.
Install and confirm your baseline. The package targets PHP 8.1+ backed enums and Laravel's container/translation layers, so check composer show webfox/laravel-backed-enums after installing and glance at the tag list — at 44 stars and a 2026-04 release, pin the minor ("^1.0" or whatever range the README declares) rather than tracking dev-main.
composer require webfox/laravel-backed-enums
If the README mentions a publishable config or translation stub, run the publish command once and commit the result so every developer and CI environment resolves labels identically:
php artisan vendor:publish --tag=backed-enums
Where to look first. Read the package's trait list before writing any code — the whole value proposition is compositional traits (HasLabel, HasLocalization, HasDescription, HasColor/HasIcon, comparison helpers, default-case support). Skim the README's trait table, then open vendor/webfox/laravel-backed-enums/src and read the two traits you actually intend to use. They are small; ten minutes there answers every "what does label() fall back to?" question that a README skim won't.
First use case: a status column. Take an existing string column and back it with an enum.
namespace App\Enums;
use Webfox\LaravelBackedEnums\HasLabel;
enum OrderStatus: string
{
use HasLabel;
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function label(): string
{
return match ($this) {
self::Pending => 'Awaiting payment',
self::Paid => 'Paid',
self::Shipped => 'Shipped',
self::Cancelled => 'Cancelled',
};
}
}
Then wire it into a model, a migration ($table->string('status')->default(OrderStatus::Pending->value) — never a native enum() column), a cast, and a Blade select in one pass:
protected $casts = ['status' => OrderStatus::class];
<select name="status">
@foreach (OrderStatus::cases() as $case)
<option value="{{ $case->value }}" @selected($order->status === $case)>
{{ $case->label() }}
</option>
@endforeach
</select>
That single loop is the mental model for the rest of the package: value is for the database and the wire, label() is for humans, and everything else is sugar around that split.
Two-tier enums: domain vs. presentation. Keep the raw backed enum on the model, and let labels live either in a match (simple, static-analysis friendly, no translation loading cost) or in translations (needed the moment a second locale appears). A useful convention that scales: domain enums carry only HasLabel + comparison traits; UI-heavy enums (colors, icons, ordering) are separate enums or a small presenter class. Mixing HasColor into OrderStatus because one dashboard needs a badge color is how enums become dumping grounds.
Localization as the default for anything user-facing. Once the second locale lands, a match statement becomes a merge conflict. Put labels in lang/{locale}/ files and let HasLocalization resolve them. The package derives a translation key from the enum class and case name — the exact convention (class basename, kebab/snake transformation, nested keys) is the one thing worth verifying empirically before you commit a hundred keys:
php artisan tinker
>>> App\Enums\OrderStatus::Pending->label()
Then find that exact string in lang/ and match its shape. Do this before authoring translation files, not after — a wrong key convention silently falls back and you find out in production.
options()-style maps for forms, filters, and admin panels. Whatever the package calls the value→label map (options(), select(), toArray()), it exists so you never hand-roll array_column. Use it for Blade selects, Livewire/Filament/Nova option arrays, and query-string filters:
Select::make('status')->options(OrderStatus::options());
Wrap it in a Blade component (<x-enum-select :enum="OrderStatus::class" />) so every dropdown in the app behaves the same way.
Validation: Rule::enum() plus a values guard. Laravel's native Rule::enum(OrderStatus::class) covers inbound requests. Add the package's value-list helper only where you need to validate against a subset or generate a JS-side allowlist:
$request->validate([
'status' => ['required', Rule::enum(OrderStatus::class)],
]);
// For a "transition allowed" guard
if (! $order->status->in(OrderStatus::Paid, OrderStatus::Shipped)) { abort(422); }
Comparison helpers in policies and state machines. Use is(), isNot(), in(), notIn() instead of ===/in_array($x, [...], true) — the win is readability in guard clauses and consistency when someone later swaps the enum for a richer object:
public function cancel(User $user, Order $order): bool
{
return $user->is($order->user) && $order->status->is(OrderStatus::Pending);
}
Type-hint against the package's enum interface (if it ships one) rather than the concrete enum in shared helpers, so generic code can accept any backed enum:
function labelFor(\Webfox\LaravelBackedEnums\BackedEnum $case): string { return $case->label(); }
Default case for new models. Rather than sprinkling OrderStatus::Pending through controllers, mark a default case with the package's default attribute/static (check which your version supports) and read it once in the model:
protected $attributes = ['status' => OrderStatus::Pending->value];
// or, if the package exposes it:
$order->status ??= OrderStatus::default();
Keep the migration default and the enum default in sync — they're two separate truths and drift is silent.
Testing pattern. A single data provider over Enum::cases() catches the two failures that actually happen: a missing translation key and a match arm that throws UnhandledMatchError.
it('has a label for every case', function (OrderStatus $status) {
expect($status->label())->toBeString()->not->toBeEmpty()
->not->toContain('enums.');
})->with(OrderStatus::cases());
Run that with the locale forced to each supported language, since label() resolves through the translator.
Integration tips. Cast enums in API resources deliberately: ->value in JSON, label() only in admin/HTML surfaces. In Filament/Nova, enum casts plus options() give you badges and filters nearly for free. In seeders and factories, use the enum (OrderStatus::Paid) rather than the raw string — it keeps refactors honest and lets grep find usages.
Translation caching bites immediately. Laravel caches compiled translations; after adding or renaming keys, run php artisan optimize:clear (or at minimum php artisan view:clear) before concluding the package is broken. In CI, a stale bootstrap/cache is the usual culprit for "works locally, fails in the pipeline."
Missing keys may render the raw key. Depending on fallback behavior, an unresolved label surfaces as enums.order_status.pending in your UI. That's a better failure mode than a silent empty string, but it means the "no key" test above is mandatory, not optional.
Locale state vs. static memoization. If labels are cached in a static array for performance, a locale switch mid-request (or an Octane worker handling two locales) can serve stale English to a French user. Either don't cache, or key the cache by app()->getLocale() and clear it in a test setUp().
Trait method collisions. Adding HasLabel to an enum that already declares label() is fine in PHP but produces confusing precedence; adding two traits that both define label() is a fatal error. Decide once: match or trait, not both, in the same enum.
toArray()/toJson() leaking into APIs. If the enum implements JsonSerializable via a package trait, response()->json($order) may emit a label or an object where clients expect a string. Assert the serialized shape in a feature test before shipping.
Never make a native enum() DB column. Use string() with an application-level default. Adding a case in 2027 then requires a migration on MySQL and is awkward on Postgres; the enum belongs in PHP, the column is a string.
Comparison subtleties. is() is null-safe-ish and reads better, but in() with an empty array is silently false — fine, but don't use it as a validation substitute. And remember $status->value ?? null fails for a null enum; guard with $status?->value.
Attribute-based overrides have precedence. If the package supports per-case attributes (e.g. a #[Label]), know the resolution order (attribute → translation → method → case name, or whatever the README states) before you debug why one case ignores its translation.
Static analysis friction. PHPStan/Psalm will flag label() on a plain \BackedEnum. Annotate with the package interface and add @method docblocks on magic statics like options() if the package relies on __callStatic.
Extension point: your own trait. Rather than editing vendor code, write app/Enums/Concerns/HasBadge.php that calls $this->label() and $this->color() and composes with the package traits — that's the intended seam and it survives upgrades.
Small-package risk management. With 44 stars and a single maintainer, treat the package as a dependency with a real bus factor: keep vendor-specific calls behind one or two app-level helpers (enum_label($case)), so replacing the package with 40 lines of your own trait is an afternoon rather than a refactor. Read the diff before bumping the minor version, and pin in composer.json.
How can I help you explore Laravel packages today?