idez/nova-checkboxes-field
A Laravel Nova field package that adds a checkbox group input to resource forms, letting users select and store multiple options at once. Drop-in checkboxes field with native Nova styling and minimal setup for multi-select values.
1. Audit compatibility before you write a line of feature code. The last release was June 2022, so this field predates Nova 4.30+ and all of Nova 5. It ships a prebuilt dist/js/field.js compiled against whatever Vue version was current then; a Vue 2 bundle installs cleanly into a Vue 3 Nova and then fails at runtime with a blank cell or Failed to mount component. Check before anything else:
composer require idez/nova-checkboxes-field
cat vendor/idez/nova-checkboxes-field/composer.json # laravel/nova constraint
cat vendor/idez/nova-checkboxes-field/package.json # vue version
grep -r "class .* extends Field" vendor/idez/nova-checkboxes-field/src
The grep gives you the real namespace and class name — the README is the only other place it's documented, and with 5 stars and 0 dependents you should assume nothing is verified by anyone but you.
2. Read the Vue components before writing a migration. This is the single highest-leverage ten minutes you will spend on the package:
resources/js/components/FormField.vue — what this.field.value is initialized as, and exactly what this.$emit('input', …) emits. Array? JSON string? Nested object in grouped mode?IndexField.vue / DetailField.vue — how null renders (does it call .join() unguarded?) and whether grouped values get flattened for display.Everything downstream — column type, cast, validation rule, filter, accessor — depends on the answer. Decide once, write it in a comment on the migration, move on.
3. Migration and cast (adjust to what you found in step 2; the flat case is almost certainly a JSON array):
Schema::table('users', fn (Blueprint $t) => $t->json('channels')->nullable());
protected $casts = ['channels' => 'array'];
4. First use case — a multi-select "notification channels" field, which is the canonical fit: a handful of static options, stacked checklist affordance, no relation to maintain:
use Idez\NovaCheckboxes\Checkboxes; // confirm in step 1
Checkboxes::make('Channels')
->options([
'email' => 'Email',
'sms' => 'SMS',
'push' => 'Push',
])
->rules('nullable', 'array'),
If the component doesn't render at all, republish assets — there is no config file, so nothing else to debug here:
php artisan vendor:publish --provider="Idez\NovaCheckboxes\FieldServiceProvider" --force
php artisan nova:publish
Select API->options() takes the same [value => label] array as Nova's built-in Select, so anything pluck()-able works directly. Keys are what get stored:
Checkboxes::make('Roles')
->options(Role::orderBy('name')->pluck('name', 'id'))
The release adds grouped rendering — one field, several labelled checkbox blocks. It is effectively a permission-grid UI:
Checkboxes::make('Permissions')
->groups([ // confirm method name in the README
'Content' => [
'posts.create' => 'Create posts',
'posts.delete' => 'Delete posts',
],
'Users' => [
'users.invite' => 'Invite users',
'users.ban' => 'Ban users',
],
])
Before building on it, verify two things — from the README example, FormField.vue, and the network tab on save:
['posts.create','users.ban']) or a nested array mirroring the groups (['Content' => ['posts.create']]).It is almost certainly flat. But validation, filters, whereJsonContains, display formatting, and every diff you ever run against it depend on that answer. Settle it on day one.
Grouped mode doubles the drift risk: the array fed to groups() and the allow-list used for validation must stay identical or users get rejected for checking a box you rendered. Put it behind a static:
// app/Nova/Fields/PermissionCheckboxes.php
public static function groups(): array { /* single source of truth */ }
public static function make(string $attribute = 'Permissions'): Checkboxes
{
return Checkboxes::make($attribute)->groups(static::groups());
}
Then flatten the same source for the rule: array_keys(Arr::dot(self::groups())).
Nova applies field rules to the attribute, so 'channels.*' won't attach to the field itself. Two workable routes:
// a) whole-attribute rule
->rules(['array', new AllowedChannels])
// b) element rules in NovaRequest / FormRequest
'channels.*' => [Rule::in(array_keys(Channel::options()))],
Do not rely on groups() being enforced server-side. A crafted /nova-api payload can submit arbitrary keys — including your group labels, since nothing in the field stops it.
If FormField.vue emits a JSON string instead of an array, convert once at the field rather than patching vendor code:
->fillUsing(function ($request, $model, $attribute) {
$value = $request->input($attribute);
$model->{$attribute} = is_string($value) ? json_decode($value, true) : $value;
})
->resolveUsing(fn ($value) => $value ?? [])
resolveUsing returning [] rather than null avoids the create-form render loop choking on a missing value — grouped mode has more moving parts in that loop and is likelier to trip.
The built-in rendering is plain and grouped values have no obvious single-line form. Override it, mapping against a flattened label lookup (Arr::dot() of the groups array) when grouped:
->displayUsing(fn ($value) => collect($value ?? [])
->map(fn ($k) => $labels[$k] ?? $k)
->implode(', '))
Or ->hideFromIndex() and add a badge/text column — often nicer for a permission set anyway.
Straightforward on MySQL 5.7+ / Postgres, provided the stored value is a flat array:
$query->whereJsonContains('channels', $value);
SQLite (your test env) has no whereJsonContains; use where('channels','like',"%\"{$value}\"%") or normalize to a pivot table. Note whereJsonContains('channels', 1) will not match a stored "1" — see Gotchas.
The field maps to one column, not a pivot. You can bridge to belongsToMany with fillUsing (sync) + resolveUsing (pluck), but at that point a native BelongsToMany field with ->multiple() is less code and no unmaintained dependency.
No tests ship with the package. One feature test against the Nova API catches most failure modes, and is the fastest way to discover what grouped mode actually persists:
$this->putJson("/nova-api/users/{$user->id}", ['permissions' => ['posts.create','users.ban']])
->assertOk();
$this->assertSame(
['posts.create','users.ban'],
collect($user->fresh()->permissions)->sort()->values()->all()
);
Assert the exact array, not just assertOk() — that's what catches group labels and nested shapes leaking into storage.
whereJsonContains and a day of refactoring filters, accessors, and diff logic.v-model on a nested object happily emits group keys alongside leaf keys, so you silently persist 'Content' next to 'posts.create'. Your exact-array assertion catches this; assertOk() does not.null or '' instead of [] — fatal on a NOT NULL JSON column or in an accessor foreach. Grouped mode raises the odds of a sparse/nested payload when one whole group is unchecked. Guard both the fill and the resolve side.1,2,3 round-trip through JSON and the browser as "1","2". whereJsonContains('channels', 1) won't match. Pick string keys throughout; in_array((string) $id, $value, true).searchable() is a trap on JSON columns. Nova generates a LIKE against the blob — false positives across keys, worse now that group labels live in the blob too. Add a model scope using whereJsonContains, or denormalize a channels_text column for the index column to search.sort($value) before comparing, diffing ("did permissions change?"), or storing.IndexField.vue may call .join() on null; pre-existing records then show a broken cell. Backfill or override with displayUsing. The grouped index path is newer and less exercised — check it explicitly rather than assuming it behaves like the flat one./nova-api/{resource}/{id} → inspect the key in the response → match your cast. Server side: ->fillUsing(function () { logger(request()->all()); }) shows exactly what Nova receives.src/ + resources/js/ into nova-components/checkboxes/, rebuild, depend on the local path. MIT makes this legitimate, and given the release cadence it is the realistic long-term plan — budget the fork when you adopt, not when you're surprised.BooleanGroup (stores {"key": true}, renders toggle pills) and Select::make()->multiple()->options(...) cover most checkbox needs at zero maintenance. Grouped mode narrows the gap — BooleanGroup can't group — but if your "groups" are really just categories, a flat field with sorted options gets you most of the way. Choose this package when you specifically want the stacked, grouped checkbox-list UI and are willing to own the fork.Field there is no canSee/canUpdate logic. Gate sensitive option sets in the resource's fields() with a policy check — that matters more now that a single field can render a whole permission matrix.How can I help you explore Laravel packages today?