fourstacks/nova-checkboxes
fourstacks/nova-checkboxes adds a multiple-checkbox field to Laravel Nova, letting admins select several options for one attribute. Define option lists in PHP, render them as checkboxes, and save selected values as an array—useful for settings, tags, permissions, and multi-select forms.
fourstacks/nova-checkboxes gives you a Checkboxes Nova field: a group of native checkboxes in a resource form, whose selected keys are persisted as a JSON array on the model. Reach for it when a record needs a small, fixed set of independent flags — notification channels, feature entitlements, permissions, content categories — and a multi-select dropdown would hide the options behind a click.
Before you run composer require, check the Nova constraint. The package is archived, its last release was December 2019, and it targets Nova 2.x. Open the repo's composer.json and compare the laravel/nova requirement against what your app actually runs. If you are on Nova 4+, stop here and use the native BooleanGroup field instead — same JSON array storage shape, zero migration of existing data:
use Laravel\Nova\Fields\BooleanGroup;
BooleanGroup::make('Features')->options(Features::ALL),
If you are on Nova 2/3 and want the field anyway, install it:
composer require fourstacks/nova-checkboxes
There is no vendor:publish step — the service provider is auto-discovered and registers the compiled asset via Nova::script(). If the field renders as an empty table cell later, that registration is the first thing to check.
Database and cast, done before the field. Both of these are mandatory, and forgetting either is the single most common reason the field "doesn't work":
// migration
$table->json('features')->nullable();
// model
protected $casts = ['features' => 'array'];
Without the json/text column and the array cast, saving throws Array to string conversion, or the field reloads unchecked despite a successful-looking submit.
Minimal field definition. Add it to fields():
use Fourstacks\NovaCheckboxes\Checkboxes;
public function fields(Request $request)
{
return [
Checkboxes::make('Features')->options([
'newsletter' => 'Newsletter',
'product_updates' => 'Product updates',
'beta_access' => 'Beta access',
]),
];
}
Use associative arrays with stable string slugs. A plain list (['Newsletter', 'Updates']) stores integers 0,1, and inserting or reordering an option silently reinterprets every existing record. This is not negotiable.
Where to look first when something breaks. Read src/Checkboxes.php for the field class and options()/resolve logic, then resources/js/components/FormField.vue and IndexField.vue to see exactly how the value is shaped on the wire. dist/js/field.js is a committed build — if you fork, you must rebuild it.
First day-to-day use case. A "notification channels" field on a User resource. Operators tick the boxes, the detail view renders the labels, and your queued job branches on the stored array:
$channels = (array) $user->features;
if (in_array('sms', $channels, true)) {
// send SMS
}
Own the option list in one class. Labels are needed in the form, in validation rules, in filters, in Blade views, and in jobs. Define them once so they can never drift:
// app/Support/Features.php
final class Features
{
public const ALL = [
'newsletter' => 'Newsletter',
'product_updates' => 'Product updates',
'beta_access' => 'Beta access',
];
public static function keys(): array
{
return array_keys(self::ALL);
}
}
Defaults and validation on the same field.
Checkboxes::make('Features')
->options(Features::ALL)
->default(['newsletter'])
->rules('array')
->rules(['features.*' => 'in:'.implode(',', Features::keys())]);
Note the wildcard rule has to be passed as its own array entry in a separate rules() call — that is how Nova forwards per-element rules.
A reusable field factory. If the same group appears on several resources, wrap it so options and validation stay in lockstep:
public static function features(): Checkboxes
{
return Checkboxes::make('Features')
->options(Features::ALL)
->rules('array');
}
Index and detail presentation. The shipped index/detail components render the selected labels and no more. When you want badges, counts, or a different separator, use a computed text field rather than fighting the Vue component:
Text::make('Features', function () {
return collect($this->features ?? [])
->map(fn ($k) => Features::ALL[$k] ?? $k)
->implode(', ');
})->onlyOnIndex(),
Keep the ?? $k fallback — rows written before an option was renamed will otherwise vanish from the display.
Filtering a JSON column. No filter ships with the package, so you write one. Use JSON operators, never LIKE, or beta will match beta_access:
public function apply(Request $request, $query, $value)
{
return $query->whereJsonContains('features', $value);
}
public function options(Request $request)
{
return Features::keys();
}
whereJsonContains works on MySQL 5.7+/MariaDB 10.2+ and Postgres jsonb. On SQLite fall back to json_each, or LIKE '%"key"%' — including the quotes, which is what keeps it from being a substring match.
Conditional fields. Nova's dependsOn hands the dependent field the whole array, not a boolean. Normalise inside the callback rather than trying to compare arrays in Vue:
Text::make('SMS Number')->resolveUsing(function ($value) {
return $this->resource->sms_number;
})->dependsOn(['features']),
Bulk actions and jobs. Treat the attribute as a plain PHP array and guard against null from rows created before the field existed:
$features = $action->models->pluck('features')->map(fn ($f) => (array) $f);
$betaUsers = $features->filter(fn ($f) => in_array('beta_access', $f, true));
Seeders and factories. Write the JSON array directly; there is nothing Nova-specific about persistence:
User::factory()->create(['features' => ['newsletter', 'beta_access']]);
.vue component into your own codebase under MIT rather than pinning an abandoned Composer dependency inside your authenticated admin surface.field.js.$casts beats every other bug report. Without 'array' on the model, saves fail or the field re-renders unchecked. Verify the cast before you read a single line of JavaScript.null, [], or '' back. Normalise with (array) $model->features at every read site, and use strict in_array(..., true).scopeSearch or a generated column if operators need to find records by ticked option.whereJsonContains on a large table has no index to use. Add a functional/generated column index (MySQL 8) or a GIN index on jsonb (Postgres) before the table grows, not after.dist/js/field.js, run php artisan nova:publish, and hard-refresh. A stale field.js in the browser looks identical to a broken field, and you will burn an hour on it.options() into doing it. Budget accordingly, and remember the fork is yours to maintain.BooleanGroup stores the same JSON array shape. Switching is BooleanGroup::make('Features')->options(Features::ALL) plus dropping the package, with no data transformation. Build your option constants and read sites so that swap is a one-file change.How can I help you explore Laravel packages today?