silvanite/novafieldcheckboxes
A Laravel Nova field package that adds checkbox inputs to your resources: a single boolean checkbox or a group of options stored as JSON, with labels, defaults and validation configured in PHP.
Install and register. The package is a single Nova field class shipped with prebuilt assets; there is nothing to publish or migrate.
composer require silvanite/novafieldcheckboxes
The FieldServiceProvider is auto-discovered and registers the Vue component through Nova::script(). Confirm the dist/js/field.js file exists after install — if the package was installed with --no-scripts or the dist folder is missing, the field renders as an empty column and you'll get no PHP error at all, just a blank UI.
Add the storage column first. This ordering matters more than anything else in this document.
// database/migrations/xxxx_add_channels_to_users.php
$table->json('channels')->nullable(); // MySQL 5.7+/MariaDB 10.2+/Postgres jsonb
// or, if you need portability: $table->text('channels')->nullable();
// app/Models/User.php
protected $casts = ['channels' => 'array'];
Skipping the cast is the single most common failure: the UI writes fine, but reads return the raw JSON string ["email","sms"] and the field renders nothing ticked.
Wire it into a resource.
use Silvanite\NovaFieldCheckboxes\Checkboxes;
public function fields(Request $request)
{
return [
Checkboxes::make('Channels')
->options([
'email' => 'Email',
'sms' => 'SMS',
'push' => 'Push notification',
])
->rules('required', 'array', 'min:1')
->help('Where should we notify this user?'),
];
}
First use case to try. Pick one low-risk admin resource and one short, stable option set — notification channels, notification preferences, region assignment, or a tag list under ~10 items. Verify the full loop end to end before touching anything else: save, reload the form, check the detail view, check the index view, submit with nothing ticked, and submit an invalid key via a raw request. That five-minute pass surfaces every gotcha in the last section.
Where to look first in the source. src/Checkboxes.php for the fluent API (options(), columns(), and whatever else that version exposes) and resources/js/components/Field.vue for how it reads/writes values. Read both before assuming a method exists — the README lags the code in packages of this size.
Centralize the option map. Duplicating labels between the Nova field and the validation rule is the most predictable source of drift. One source, consumed twice:
// config/checkboxes.php
return [
'channels' => [
'email' => 'Email',
'sms' => 'SMS',
'push' => 'Push notification',
],
];
Checkboxes::make('Channels')
->options(config('checkboxes.channels'))
->rules('array', Rule::in(array_keys(config('checkboxes.channels'))));
An enum with a toArray() helper works equally well and gives you type safety in application code. Either way, application code should never hardcode 'sms'.
Make the index readable. A raw JSON array in the index table is noise. Either hide it or format it:
$options = config('checkboxes.channels');
Checkboxes::make('Channels')
->options($options)
->hideFromIndex(),
// or
Checkboxes::make('Channels')
->options($options)
->displayUsing(fn ($value) => collect($value ?? [])
->map(fn ($key) => $options[$key] ?? $key)
->implode(', '))
->onlyOnIndex(),
The ?? $key fallback matters: option keys get renamed and removed over time, and stale keys in the database shouldn't render as blanks or throw.
Query against the JSON column. Most of the day-to-day value is in being able to find records by selection:
User::whereJsonContains('channels', 'sms')->get();
User::whereJsonContains('channels', ['email', 'sms'])->get(); // both
On MySQL this needs a functional index or a generated column if the table is large; on Postgres use jsonb. If you find yourself writing these constantly, add a small query scope — User::notifiesVia('sms') — and back it with the JSON query.
Add a Nova filter for the field. The field has no built-in filtering. A handful of lines gets you a checkbox filter that reuses the same option map:
public function apply(Request $request, $query, $value)
{
return $query->whereJsonContains('channels', $value);
}
Normalize the empty state in a mutator. Decide once whether "nothing ticked" means [] or null, and enforce it where the value lands rather than in every consumer:
public function setChannelsAttribute($value)
{
$this->attributes['channels'] = json_encode(array_values($value ?? []));
}
Pick a convention and stay on it. Mixed null/[] in the same column produces subtle bugs in whereJsonContains and in any Blade template doing count().
Handle legacy data with resolveUsing. When migrating from a comma-separated column or an old single-select, let the field read the old shape while the migration runs:
->resolveUsing(fn ($value) => is_string($value) ? explode(',', $value) : $value)
Test the write path in a feature test, not in the browser. Nova's update endpoint takes the raw array, so a plain feature test is faster and more reliable than clicking:
$this->putJson("/nova-api/users/{$user->id}", [
'channels' => ['email', 'sms'],
])->assertOk();
$this->assertEquals(['email', 'sms'], $user->fresh()->channels);
Keep tests for the three cases that break: single selection, empty selection, and an invalid key.
The cast is not optional. Without 'channels' => 'array' (or 'json'), saving produces Array to string conversion, or worse — a silently stringified value that appears to save and then renders blank on reload. Land the column and the cast in a separate PR before exposing the field in the UI.
Integer option keys become strings. ['1' => 'One'] round-trips through JSON as "1", and in_array($key, $options, true) then fails. Either use string keys deliberately or compare loosely. This bites hardest when options are generated from an ID list.
Options are not validated by the field. Nova will happily accept any array payload the browser sends. Add Rule::in(array_keys($options)) on the resource, and mirror it in your form requests if the same column is writable outside Nova.
Nova version compatibility is the real risk. This package has modest adoption and no known dependents, so it will not be actively chased across Nova majors. Before adopting, check composer.json's constraints against your Nova major, and check the last commit date. If the assets don't build against your Nova frontend, the fallback is small: a custom field wrapping BooleanGroup, or a fork of this one rebuilt from resources/js. Plan for that — budget half a day and name an owner — rather than discovering it mid-upgrade. Add "re-verify the checkboxes field" to your Nova upgrade checklist.
Check native BooleanGroup first. Nova ships a field that stores multiple selections as JSON. If your options are simple on/off toggles, BooleanGroup covers it with zero dependencies. This package earns its place only when you need labeled key/value options, a specific column layout, or a particular index display.
Cap the option count. Under about 20–25 items is comfortable. Beyond that the checkbox wall becomes harder to scan than a searchable multi-select, and users start missing options at the bottom. If the list is dynamic or user-scoped, use a relation or a custom field instead.
No reactive or dependent behaviour. The field doesn't support dependsOn, conditional display based on its own selections, or cross-field logic. If you need "select X to reveal Y," this is the wrong tool — write the field yourself or use a dependency-aware package.
Empty submission is [], not absent. Nova posts an empty array when nothing is ticked, which means required alone won't fail the way you expect — pair it with min:1 on the array. Conversely, if you want "no selection" to be legal, make the rules array and nullable and normalize on the model.
Sorting by a JSON column is not what you want. ->sortable() on the field sorts by the raw JSON string, so results look arbitrary. Sort by something meaningful, or derive a boolean column (e.g. has_sms) and sort that.
Stale keys accumulate. When you remove an option from config, records keep the old key. Decide whether to strip on save, tolerate on read (the ?? $key fallback above), or write a one-off backfill. Tolerating on read is usually the right default — it never loses data and never crashes a detail view.
How can I help you explore Laravel packages today?