dillingham/nova-items-field
A Laravel Nova field for managing repeatable items stored in a JSON column. Add, remove, reorder, and search items with a clean, customizable UI. Supports validation, custom templates, and easy integration with Nova resources—ideal for tags, lists, and key-value data.
1. Verify Nova 3.x first — this gates everything. Run composer show laravel/nova. The field is a Vue 2 / Laravel Mix component (one PHP class in src/, one Vue component in resources/js/). On Nova 4 (Vue 3) or Nova 5 (Vite/PHP 8.1+) it will fail at build time. If you're not on Nova 3, stop and fork instead of installing.
2. Install and pin to an exact minor. Pre-1.0 means a minor bump can break:
composer require dillingham/nova-items-field:0.6.*
Pin 0.6.* (not ^0.6) — 0.6 is where nullable-column support landed (PR #14), and you don't want to drift onto an unexpected release.
3. Create a json column and cast it. The field maps one array to one column:
$table->json('features')->nullable();
// Model
protected $casts = ['features' => 'array'];
The cast is not optional — without it the field will render empty and saves will behave unpredictably.
4. Register it on the resource. The sub-fields you pass to ->fields() define the shape of every repeated row:
use Dillingham\NovaItemsField\Items;
public function fields(Request $request)
{
return [
Items::make('Features')
->fields([
Text::make('Label', 'label'),
Text::make('URL', 'url'),
]),
];
}
5. First use case — prove the round-trip. Pick a resource with a simple flat ordered list (feature bullets, menu links, FAQ entries). Create a record, add two items, drag to reorder, save, reload, and inspect the raw JSON in the DB. Then create a record with null in the column and confirm it loads and saves without coercing to [].
Where to look first. Read the PHP field class and the Vue component in full before touching config — that is the entire package. There is no service provider hooking the request lifecycle, so debugging is confined to the form component and how it serializes to the model.
Sub-fields are the schema. Every item renders the exact field set you pass. Keep it flat — repeatable list, not nested repeater.
Items::make('Links')
->fields([
Text::make('Title', 'title')->rules('required|string|max:100'),
Text::make('URL', 'url')->rules('required|url'),
])
->addLabel('Add link')
->removeLabel('Remove');
Put validation in the resource, not the package. The field does not enforce shape. Encode the contract with wildcard rules plus an array-level bound:
public function rules(Request $request)
{
return [
'links' => ['array', 'max:10'], // add 'nullable' only if null is meaningful
'links.*.title' => ['required', 'string', 'max:100'],
'links.*.url' => ['required', 'url'],
];
}
Roll out read-only first. Because the package writes the whole array on save, ship it gated and watch a full lossless round-trip (including null) before granting edit access. Nova's standard field API covers this — the package adds nothing:
Items::make('Features')->readonly(fn () => ! Auth::user()->can('edit-features'))
Reorder is free, but order is not a contract. Drag-and-drop persists as array order in the JSON with no config. Don't depend on that order server-side for anything needing deterministic sorting — sort explicitly on read.
Standardize on one "empty." If null isn't semantically distinct from [] in your domain, normalize in the resource layer (mutator or accessor) so every consumer sees one representation. If it is distinct, document it once and validate accordingly.
Treat the shape as a small public contract. It's a leaf field with no queue, auth, or routing dependencies. Keep the JSON shape internally owned and stable, and validate it in exactly one place. A migration adding a sub-key is a data migration, not just a code change — old rows won't have it:
// backfill when adding a key to an existing shape
DB::table('pages')->whereNotNull('features')->orderBy('id')->each(function ($row) {
$features = collect(json_decode($row->features, true))
->map(fn ($f) => $f + ['external' => false])
->all();
DB::table('pages')->where('id', $row->id)->update(['features' => json_encode($features)]);
});
Testing. In factories/tests, set the column as a PHP array and let the cast handle it. Add one feature test asserting the exact persisted JSON, and one asserting a null record survives an update untouched.
Gate against the relational temptation. The moment you want to search, filter, sort server-side, or relate items to other models, stop — use hasMany plus an inline relationship field. This package is for lists edited as a whole.
hasMany if concurrent editing is expected."1" vs 1, null vs [], orphaned keys after a shape change). Any consumer outside Nova — API, export, analytics — must handle it, or normalize in a dedicated resource layer.null vs [] is now a real distinction. 0.6 makes null storable and editable, which means every consumer branches on two "empty" states. Decide the semantics once and document it.0.6.*, never ^0.6.laravel/nova dependency means builds require Nova repo auth in CI, and security scanning on that private dep is limited. Everyone who touches the field inherits that.json (not text) and the model casts it to array.array rule kills the write without an obvious field-level error.How can I help you explore Laravel packages today?