blendbyte/nova-items-field
A Laravel Nova field for managing repeatable items as JSON. Define item fields in PHP, let editors add, reorder, and remove rows, and store structured array data on your models—ideal for flexible content blocks and simple repeaters.
Install — Composer only. No service provider, no config file, no migrations shipped by the package.
composer require blendbyte/nova-items-field
Hard requirement: Laravel Nova 5. If the project is on Nova 4, you want the original dillingham/nova-items-field instead — the two packages collide in composer.json (same class name concept, different vendor segment) but produce byte-identical JSON, so switching later needs no data migration.
If the runtime is PHP 8.4+, pin ^1.3.5 as the floor:
"blendbyte/nova-items-field": "^1.3.5"
Earlier 1.3.x tags declare the Items constructor with implicit nullable parameters and emit a deprecation notice on every boot.
Where to look first, in order
README.md — it's short, and it is the API contract. The builder chain (keyLabel, valueLabel, heading, help, default, hideFromIndex/hideFromDetail, rules) is the entire public surface.src/Items.php — roughly 200 lines. Every builder method is a thin withMeta() call, so reading it tells you exactly what props the Vue component receives. This is the file to open when a fluent method "doesn't do anything."resources/js/components/FormField.vue — defines the drag/drop behaviour and the exact persisted shape. Read before customising.dist/ — compiled assets are committed to the repo, so no npm install is required for normal use.The data shape is one JSON column holding an ordered array of exactly two keys:
[
{ "key": "GitHub", "value": "https://github.com/blendbyte" },
{ "key": "Docs", "value": "https://example.com/docs" }
]
Minimum viable wiring
// migration
$table->json('links')->nullable();
// model
protected $casts = ['links' => 'array'];
use Blendbyte\NovaItemsField\Items;
public function fields(Request $request)
{
return [
Items::make('Links')
->keyLabel('Label')
->valueLabel('URL')
->heading('Links')
->help('Drag the handle to reorder. Blank URLs are hidden on the site.')
->rules('array')
->onlyOnForms(),
Text::make('Links', fn () => collect($this->links ?? [])
->map(fn ($l) => '<a href="'.e($l['value']).'">'.e($l['key']).'</a>')
->implode(' · '))
->asHtml()
->onlyOnIndex(),
];
}
First use case to ship — a "spec links" or "opening hours" block on a settings/page resource. Two attributes per row, ordering matters, no need to query across rows. That's the sweet spot and it takes under ten minutes end-to-end.
The columns are hard-coded as key and value; the label methods are cosmetic only. Reach for it whenever your row shape is exactly two fields:
| Use case | keyLabel |
valueLabel |
|---|---|---|
| Spec links | Label | URL |
| Opening hours | Day | Hours |
| Feature bullets | Title | Description |
| Redirect map | From | To |
| Product specs | Name | Value |
| Pricing tiers | Plan | Price |
Three or more attributes per row means you've picked the wrong tool. The failure mode is stuffing a second JSON object into value and then hand-decoding it in every render closure.
The pattern that holds up in production: Items on forms only, a computed read-only field on index and detail. That keeps index queries cheap and puts escaping and link targets under your control.
Text::make('Links')
->displayUsing(fn () => count($this->links ?? []).' link(s)')
->onlyOnIndex(),
->rules('array') on the field only validates the outer envelope. Per-row rules belong in rules() or a form request:
public static function rules(NovaRequest $request)
{
return [
'links' => 'nullable|array|max:20',
'links.*.key' => 'required|string|max:100',
'links.*.value' => 'nullable|url|max:2048',
];
}
Per-row error messages are the main reason to pick this over a Textarea full of raw JSON. Keep the top level nullable — an empty list can submit as [] or be omitted entirely, and a bare array rule fails on null.
Drag/drop is client-side only; the persisted array order is the sort order. An accessor doing sortBy('key') silently discards whatever the editor arranged. If a particular surface needs a deterministic order, sort inside the render closure, never in the cast or accessor.
Items::make('Links')->default([
['key' => 'Website', 'value' => 'https://example.com'],
]),
// factory
'links' => [['key' => 'Twitter', 'value' => 'https://twitter.com/acme']],
->default() fires only when the attribute is null on form load. It will not backfill existing records, and it will not fire when the column already holds [].
Nova's searchable() cannot reach inside the JSON blob. If link labels must be searchable, mirror them into a flat column via an observer:
static::saved(function ($page) {
$page->links_text = collect($page->links)->pluck('key')->implode(' ');
$page->saveQuietly();
});
Then Text::make('Links', 'links_text')->searchable() works normally. Once you need this plus per-row identity, a HasMany is usually the cheaper answer.
composer remove dillingham/nova-items-field && composer require blendbyte/nova-items-fieldDillingham\NovaItemsField\Items → Blendbyte\NovaItemsField\Items->onlyOnForms() plus a computed read-only field is the default wiring.->heading() draws a label above the list — enough visual grouping to avoid a custom panel component in most cases.->help() is the only place you can advertise drag/drop; nothing in the UI hints at it beyond the handle itself.Section around it and you get a reasonable "Structured content" grouping for free.Pin ^1.3.5 on PHP 8.4+. Older 1.3.x signatures are implicitly nullable and emit a deprecation per request. The fix is signature-only, so the bump is low risk. Anyone floating on 2.0.x should run composer show blendbyte/nova-items-field --all and read the tags before assuming "downgrade."
The 1.3.5 changelog header is misleading. It opens with compare/2.0.1...1.3.5 and then states the real range as compare/1.3.4...1.3.5. GitHub picked a non-ancestor tag as the default diff base. Do not infer that 1.3.5 is a rollback of 2.0.1. Pin explicitly rather than letting ^1.0 float across an unread tag boundary.
Empty list is [], not null. The field happily submits [] over a previously null column. Pick one as canonical and normalise in an accessor, otherwise every consumer needs ?? [] and every count() needs a guard.
key and value are hard-coded in the Vue component. keyLabel() and valueLabel() only relabel. Different storage keys means forking the component — at which point writing a purpose-built field is usually cheaper.
Whole-array overwrite on save. One JSON column, no per-row IDs. Two admins editing different rows of the same record clobber each other; last write wins for the entire list, and you cannot diff or merge. If the data is genuinely collaborative, it belongs in HasMany.
Not searchable out of the box. See the observer pattern above; don't waste time trying ->searchable() on this field.
Validation keys follow the attribute, not the label. Items::make('Links', 'links_json') means your rules are links_json.*.key. Classic Nova array-field trap that surfaces as errors stuck at field level instead of attached to the offending row.
Escape on render. The field stores exactly what the user typed — no sanitising on write. Always e() values before interpolating into an anchor or an asHtml() field.
Patching assets requires rebuild and publish.
cd vendor/blendbyte/nova-items-field
npm install && npm run prod
php artisan nova:publish
Skipping nova:publish is the classic "my change did nothing" — Nova serves the cached copy from public/vendor.
Debug the raw payload, not the cast. When the form looks correct but the model is wrong:
dump($page->getRawOriginal('links')); // what actually hit the DB
dump($page->links); // what the cast produced
Nine times out of ten the raw value is "[]", null, or double-encoded because something json_encoded data that was already encoded.
Extension points are thin. Beyond the builder methods, customisation means overriding Vue components via Nova::script() / Nova::style(), or replacing the field outright. At ~200 lines, a bespoke implementation is frequently cheaper than fighting the layout.
Low bus factor — treat it accordingly. 14 stars, zero dependents, one maintainer, releases driven by outside PRs (1.3.5 is a first-time contributor's one-liner). It's MIT and tiny, so pin the version and keep vendoring into nova-components/ on the table as cheap insurance.
How can I help you explore Laravel packages today?