Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Nova Simple Repeatable Laravel Package

optimistdigital/nova-simple-repeatable

A Laravel Nova field package for adding simple repeatable rows to resource forms. It lets editors create, reorder, and remove structured JSON-backed entries without a related model, making flexible repeated inputs easy to manage in Nova.

View on GitHub
Deep Wiki
Context7

Getting Started

Confirm you have a Nova install first. This is a Nova form field, not a general Laravel package — outside a Nova resource it does nothing. Check the composer.json require block on the tag you intend to pin for the supported Nova major, not the readme.

Install, and mind the namespace. The package has lived under two vendor namespaces (optimistdigital → outl1ne). Confirm which Packagist name resolves for your Nova version, then pin an explicit constraint — this is a small-maintainer package and you should never float it:

composer require outl1ne/nova-simple-repeatable:^3.0

Use whichever class path actually exists in vendor/ after install:

use Outl1ne\NovaSimpleRepeatable\SimpleRepeatable;
// older tags: OptimistDigital\NovaSimpleRepeatable\SimpleRepeatable

If the field renders blank, you probably imported a class that doesn't exist on that tag — check vendor/ before debugging anything else.

Three steps to a working field.

  1. One nullable JSON column. No child table, no relationship:
Schema::table('locations', function (Blueprint $table) {
    $table->json('opening_hours')->nullable();
});
  1. Cast it on the model. Not optional — without it the field tries to persist a PHP array into a string column:
protected $casts = [
    'opening_hours' => 'array',
];
  1. Define the field in the resource:
SimpleRepeatable::make('Opening hours', 'opening_hours')
    ->fields([
        Text::make('Day', 'day'),
        Text::make('Hours', 'hours'),
    ])
    ->nullable(),

Pick the right pilot. Opening hours, an FAQ list, a feature/bullet list, or a contact-entries block — bounded, human-edited, display-oriented, and never the target of a WHERE or JOIN. Those transfer cleanly; the edge cases you hit in a five-field row will not.

Where to look first when something is wrong. In this order: the resource file (field definition), the model (cast), then the raw column. 90% of "the field is broken" is a missing cast or a hand-edited shape in the column, not the Vue side.

Implementation Patterns

Centralise the row schema in one place. Copy-pasted field arrays are how JSON shapes drift. Put the field set on the model so the Nova resource, factories, and tests agree on the key set:

// app/Models/Location.php
public static function openingHoursFields(): array
{
    return [
        Text::make('Day', 'day')->rules('required|string|max:20'),
        Text::make('Hours', 'hours')->rules('required|string|max:50'),
    ];
}
SimpleRepeatable::make('Opening hours', 'opening_hours')
    ->fields(Location::openingHoursFields())
    ->nullable(),

Always pass the second argument to inner make() calls. The key in the stored JSON is the field's attribute name. Relying on the label to derive it gives you Opening Hours → opening_hours-style guessing that silently changes when someone renames a label above it.

Layout: table vs stacked. Default renders inner fields side by side in a row — fine for two or three narrow inputs. From 3.0.1, ->stacked() renders each row as a vertical block. Use stacked when any inner field is a Textarea, Trix, File, or anything that needs width, or when a row has five-plus fields. It is purely cosmetic — it does not change the stored JSON shape or ordering.

SimpleRepeatable::make('FAQ', 'faq')
    ->fields([
        Text::make('Question', 'question'),
        Textarea::make('Answer', 'answer')->rows(3),
    ])
    ->stacked()
    ->minRows(1)
    ->maxRows(20)
    ->addRowLabel('Add question')
    ->nullable(),

Default the empty state at read time, not in the DB. A fresh record has null; an editor deleting every row may persist []. Normalise once so your Blade/API code never branches on both:

public function getOpeningHoursAttribute($value): array
{
    return is_array($value) ? $value : [];
}

Layer validation: Nova rules are UX, model rules are truth. Inner-field ->rules() gives inline feedback, but the JSON column is writable by seeders, imports, ->fill(), and other tools. Duplicate the contract as a hard guard:

'opening_hours' => ['nullable', 'array', 'max:20'],
'opening_hours.*.day' => ['required', 'string', 'max:20'],
'opening_hours.*.hours' => ['required', 'string', 'max:50'],

Nested validation inside repeatable fields is the classic weak spot in packages like this — verify errors actually surface against the right row in your Nova version. If they don't, move the assertion into a model rule and accept a form-level error.

Don't render the blob in the index. A JSON column shows as a wall of text. Either hide it or summarise:

SimpleRepeatable::make('Opening hours', 'opening_hours')
    ->fields(Location::openingHoursFields())
    ->hideFromIndex(),

// or, for a one-word summary:
// ->displayUsing(fn ($value) => count((array) $value) . ' rows')

Seed and test the shape explicitly. The column is opaque — a broken fixture passes silently until an editor opens the record:

it('persists repeatable rows in order', function () {
    $rows = [
        ['day' => 'Mon', 'hours' => '9–5'],
        ['day' => 'Tue', 'hours' => '9–5'],
    ];

    $location = Location::factory()->create(['opening_hours' => $rows]);

    expect($location->fresh()->opening_hours)->toBe($rows);
});

Cover: empty state, add/remove, reordering, and one record at the maximum expected row count.

Normalise on output too. Casting to array means /api/locations emits raw rows. Wrap it in an API resource (and a Nova Resource if you display it) so the shape stays stable if you later swap the field or the storage.

If you must filter on a row value, project it. Don't retrofit a query onto JSON. Add a generated column, index it, treat it as a derived read model:

$table->string('primary_phone')->storedAs("json_unquote(json_extract(`contacts`, '$.0.phone'))");

Writing the third whereJsonContains is the signal to migrate to a child model.

Migration path is cheap — keep it that way. JSON stays human-readable, so moving to a normalized model later is a decode-and-backfill job. Keep the resource definition thin (a static field-set method, nothing clever inline) so a swap is a contained change.

Gotchas and Tips

Cast or die. Missing the array cast produces "array to string conversion" on save, or a JSON-encoded string that decodes to null on read. If data looks empty after a successful save, dump DB::table('locations')->value('opening_hours') — if it's a quoted JSON string, your cast is wrong.

Null vs []. ->nullable() plus "delete all rows" can persist either, and behaviour varies across Laravel versions. Pick one, normalise in an accessor, or you'll write ?? [] in fifteen places.

There is no row identity. Rows are ordered array entries with no stable ID. Removing row 2 renumbers everything after it; you cannot reference "row 3" across saves. If you need a durable handle (deep links, per-row sync), add a Hidden::make('id') inner field and populate a UUID — and accept it's convention, not a guarantee, since the package neither enforces nor preserves it.

File uploads leak. A File/Image inside a row stores a path in the JSON. Deleting the row removes the reference but not the file. Prune orphans with a scheduled job that diffs disk contents against referenced paths across all records using that column, or you'll accumulate storage silently for years.

Relationship fields store bare IDs. A BelongsTo inside a row writes an integer; there is no foreign key, so deleting the related model leaves a dangling reference and a broken editor preview. Avoid relational fields inside rows, or add an integrity check in a model observer.

Last write wins. The entire array is written in one request. Two editors on the same record — or one editor with two tabs open — means silent data loss with no version, diff, or audit trail. If that matters, you need a child table.

Assume reactive and nested usage is unsupported. dependsOn, conditional hide/show closures keyed off sibling row values, and repeatables-inside-repeatables are the usual breakage points. Spike them on a branch before designing a screen around them.

Don't try ->searchable() or a filter on it. Nova search runs against the column as a string; matching on opaque JSON gives nonsense results and slow scans. Keep it out of search and index filters entirely.

Size sanity. MySQL JSON is capped by max_allowed_packet, and the whole document is rewritten on every save. A few dozen rows per record is fine; a few thousand is not. Also check whether the column leaks into any API response or export — a large blob there is a real cost.

Cache clearing is the first debugging step. After install or upgrade: php artisan view:clear, php artisan config:clear, and php artisan nova:publish if assets look stale. A blank or unstyled field is almost always compiled assets or a wrong namespace import — not the package logic.

Debug from the wire backwards. When a save misbehaves, check in this order: the network payload in the Nova save request (is the array there?), the raw column value (did it land?), the model attribute after fresh() (does it cast back?), then the Vue rendering. That ordering resolves most issues in under a minute and tells you whether it's a field bug or your cast/shape.

Pin, own, and plan the upgrade. Commit the lockfile, set an explicit constraint, and name an owner for Nova-major upgrades. When Nova ships a major this field will lag; MIT licensing means forking is the mitigation. On any upgrade, diff composer.lock in a branch — release notes here list features like ->stacked() plus an unspecified "updated packages", which is not a changelog.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
alphabalex/messaging-made-easy
rawphp/warp
shootkiran/merobug
narakode/fineauth
numencode/wn-syncops-plugin
ibnnajjaar/mib-global-pay
ably/pubsub-server
mstfkhazaal/defender
mfonte/base62x
karnoweb/metapilot
tobya/bcsapiwrapper
swalker2/cpanel
vish4395/laravel-translation-manager
morningtrain/economic
teksite/systeminfo-laravel
atomcms/voting
run6/weather
siokas/laravelembeddirectives
adamlien/pkuuids
gilsonsouza/lara-doc-generator