norman-huth/nova-radio-field
A Laravel Nova radio field that lets you present selectable options as radio buttons inside forms. Provides a simple, native-feeling alternative to dropdowns for single-choice fields, making options visible at a glance and improving clarity for small sets of choices.
Before you write a line of resource code, check two things in this order.
laravel/nova in composer.lock. Nova 4.27+/5.x ship a native Radio field. If you have it, use it — this package is archived (last release December 2023) and adds nothing you don't already own.Muetze42), and the PHP namespace (NormanHuth\...) do not agree. Open vendor/norman-huth/nova-radio-field/src/ and read the actual class and autoload.psr-4 block before you write a use statement. A wrong guess produces a class-not-found deep inside a resource definition, which is a miserable place to debug from.Install (dependency route, only if policy requires it):
composer require norman-huth/nova-radio-field # pin an exact version in composer.json
php artisan nova:publish
composer require succeeding tells you nothing about whether the field works. The package ships a compiled dist/ bundle, and a prebuilt bundle compiled against a different Nova minor can load a 404 or blow up at runtime while the PHP side is perfectly fine. Open the browser, watch the network tab for dist/js/field.js, and watch the console before you debug anything in PHP.
The vendor-first route is usually better here. The MIT license lets you copy the field class and its Vue component into app/Nova/Fields/ with the copyright notice intact. That converts an abandoned remote dependency into first-party code you can lint, test, and patch without a supply-chain tail. Budget roughly a day including the test matrix below — that is the real cost of either route.
First use case: one resource, one genuinely bounded enum (2–5 values), on the form and detail views only.
use NormanHuth\NovaRadioField\Radio; // verify against src/
public function fields(NovaRequest $request): array
{
return [
Radio::make('Priority', 'priority')
->options([
'low' => 'Low',
'normal' => 'Normal',
'high' => 'High',
])
->default('normal')
->rules('required', 'in:low,normal,high'),
];
}
No migration, no cast, no table. The stored value is an ordinary string column; the field only swaps the input widget and the detail-view rendering.
Options live in one place, always. The recurring bug with every radio field is drift between the options() array, the validation rule, and whatever the model does with the value. Back the field with an enum:
enum TicketPriority: string
{
case Low = 'low';
case Normal = 'normal';
case High = 'high';
public static function options(): array
{
return collect(self::cases())
->mapWithKeys(fn ($c) => [$c->value => $c->name])
->all();
}
}
Then ->options(TicketPriority::options()) in the resource and Rule::enum(TicketPriority::class) in the FormRequest. One definition, three consumers, zero drift.
Wrap it in a named subclass once more than one resource needs the same enum. This is also the exact shape of the vendored option — copy upstream, rename into your namespace, add an attribution header:
namespace App\Nova\Fields;
class PriorityRadio extends Radio
{
public function __construct($name = 'Priority', $attribute = null, $resolveCallback = null)
{
parent::__construct($name ?? 'Priority', $attribute, $resolveCallback);
$this->options(TicketPriority::options())
->default(TicketPriority::Normal->value);
}
}
Now the resource reads PriorityRadio::make('Priority') and the options, default, and label set are identical everywhere.
Server-side validation is the trust boundary, not the field. ->rules() on a Nova field is a UX affordance and a client hint. A crafted request bypasses it. Enforce the same constraint in the FormRequest (or Resource::validationRules if you don't use FormRequests) with Rule::enum(...) or in:....
Detail and index behave differently — verify, don't assume. The package resolves the label on the detail view. The index column often renders the raw stored value. If operators need the label in the index too:
->displayUsing(fn ($value) => TicketPriority::tryFrom((string) $value)?->name)
Test the index rendering separately from the detail rendering; they are different code paths.
Nullable and default are not the same thing. A radio has no inherent "empty" state. If the column is nullable and the field is optional, add an explicit empty option and assert what gets persisted when nothing is picked — null, '', or a missing key from the request. Write the expected behavior down; an archived package will never document it for you, and the difference matters to downstream queries.
Conditional fields are the classic failure surface. If the resource uses dependsOn/DependsOn or ->hide()/->show() driven by other fields, verify the radio re-renders and re-validates correctly when it appears and disappears. This is precisely the class of bug upstream will not fix, and it is common enough that you should include it in your staging spike by default.
Roll out behind one resource. Ship on a single resource, run it through one full deploy cycle, then widen. The rollback is a one-line change back to Select, and the data stays valid because it's a plain column. That reversibility is the strongest reason to try it at all.
composer.json (no caret, no *) and keep a vendored source copy next to the lockfile so builds never need a remote that can vanish.dist/js/field.js was built against some Nova minor and some Vue/Mix-or-Vite toolchain. If the field renders blank or the console throws, it's the bundle — not your resource. php artisan nova:publish copies files, it does not rebuild them.<input value> attributes are strings. options([0 => 'Draft', 1 => 'Live']) submits "0" and "1". On MySQL in strict mode or on Postgres this can fail the insert or the enum cast. Use string enum values, or cast explicitly in the model.options(['Low', 'Normal', 'High']) gives you numeric keys and the labels become the values. Always pass an explicit key => label map.<select>. Large or dynamic option sets (countries, categories) want a searchable select, not this.hideFromIndex, readonly, help, rules, default, resolveUsing all come from the base Field. Reach for them before reaching for custom code; most "the field doesn't support X" complaints resolve to a base-class method.Select or a native Radio is a one-line resource change with no data migration. Scope the decision accordingly — it deserves an ADR paragraph, not a sprint.How can I help you explore Laravel packages today?