outl1ne/nova-simple-repeatable
A lightweight repeatable field for Laravel Nova. Define a set of fields once, then let users add, remove, and reorder rows stored as JSON, ideal for simple structured lists without extra database tables or relationships. Quick to configure and easy to use.
Install and wire up in four steps.
composer require outl1ne/nova-simple-repeatable — then check composer.lock against your Nova major. Version 3.x is the Nova 4/5 line; do not assume the tag you got supports your pinned Nova/PHP matrix, read composer.json constraints directly.Schema::table('pages', function (Blueprint $table) {
$table->json('faq_rows')->nullable();
});
protected $casts = [
'faq_rows' => 'array', // 'array' — not AsArrayObject, see Gotchas
];
Make sure the column is mass-assignable ($fillable) — Nova writes through fill(), and a silently guarded column produces a field that appears to save and then reverts on reload.
fields():use Outl1ne\NovaSimpleRepeatable\SimpleRepeatable;
SimpleRepeatable::make('FAQ', 'faq_rows')
->fields([
Text::make('Question', 'question')->rules('required'),
Textarea::make('Answer', 'answer')->rules('required'),
])
->addButtonText('Add question')
->min(0)
->max(20),
First use case to try: an FAQ block or a link list on a read-mostly "Page"/"Post" resource. It exercises the whole loop (schema, cast, field definition, save, read) in an hour, and it is the pattern you will actually reuse.
Where to look first: the README's field-method list, then src/SimpleRepeatable.php (it's small — read the fields(), min(), max(), addButtonText(), stacked() resolution and how it maps the value in/out). That file is the whole contract. If you plan to extend it, dist/js/field.js is compiled; you'd fork and npm run dev against it rather than patching vendor/.
The canonical field definition. Keep a single place — a static method on the resource or a trait — that returns the sub-field array, so index/detail/form don't drift:
public static function faqFields(): array
{
return [
Text::make('Question', 'question')->rules('required', 'max:255'),
Textarea::make('Answer', 'answer')->rules('required')->rows(3),
Boolean::make('Published', 'published'),
];
}
SimpleRepeatable::make('FAQ', 'faq_rows')
->fields(self::faqFields())
->stacked() // 3.0.1+: one block per row instead of inline
->max(20)
->addButtonText('Add question')
->help('Order determines display order on the public page.'),
Use ->stacked() as soon as a row has more than two or three sub-fields, or contains a Textarea/Select wide enough to make inline rows wrap badly.
Always pass the attribute explicitly. Text::make('Question') derives the attribute question, but Text::make('Short Question') derives short_question — and renaming a label later silently changes the JSON key and orphans existing rows. Write Text::make('Question', 'question') every single time in a repeatable.
Treat the JSON shape as owned code. The highest-value habit with this package is a cast or DTO that is the single reader/writer of the column, used by Nova fields, API resources, exports, seeders and imports:
class FaqRows implements CastsAttributes
{
public function get($model, $key, $value, $attributes): array
{
$rows = json_decode($value ?? '[]', true) ?: [];
// legacy shape migration lives here, once
return array_values(array_map(fn ($row) => [
'question' => (string) ($row['question'] ?? ''),
'answer' => (string) ($row['answer'] ?? ''),
'published' => (bool) ($row['published'] ?? false),
], is_array($rows) ? $rows : []));
}
public function set($model, $key, $value, $attributes): array
{
return [$key => json_encode(self::normalise($value), JSON_UNESCAPED_UNICODE)];
}
}
This is also your migration seam when the field set changes: add a key, backfill a default in get(), and every consumer gets the new shape for free.
Reading it downstream. Because it's just an array on the model, an API resource is trivial — and this is where you enforce the contract for non-Nova writers:
// FaqResource
'faq' => collect($this->faq_rows)->where('published', true)->values(),
Validate on write from any non-Nova path (importer, artisan command, API) with an explicit rule set; Nova's form is not a guarantee:
Validator::make($payload, [
'faq_rows' => ['array', 'max:20'],
'faq_rows.*.question' => ['required', 'string', 'max:255'],
'faq_rows.*.answer' => ['required', 'string'],
'faq_rows.*.published' => ['boolean'],
])->validate();
Validation in the Nova form. Put ->rules(...) on the sub-fields for client-side feedback, but verify server-side enforcement — add wildcard rules on the repeatable field itself so the validator sees the dotted paths:
SimpleRepeatable::make('FAQ', 'faq_rows')
->fields(self::faqFields())
->rules(['faq_rows' => 'array|max:20'])
->rules(['faq_rows.*.question' => 'required|string|max:255']) // verify passes through on your Nova version
Test this by submitting an empty row through the actual Nova form; if it saves, the nested rules are not reaching the server and you need model/FormRequest validation or a strict cast.
Index/detail views. The raw value is JSON-ish and unhelpful in a table. Either hide it from index (->onlyOnForms(), ->exceptOnDetail()) or summarise:
SimpleRepeatable::make('FAQ', 'faq_rows')
->fields(self::faqFields())
->onlyOnDetail()
->displayUsing(fn ($value) => count($value ?? []) . ' question(s)');
Per-row identity. Rows are ordered array entries with no stable ID. If you need to link to a row (anchors, referencing a specific FAQ from elsewhere), add a hidden id sub-field populated on first save via fillUsing/a saving model hook — Str::uuid() on insert. Don't rely on array index; drag-reorder changes it.
Multiple repeatables per model. Fine — one JSON column each. Keep them separate rather than one polymorphic blob, so shape drift in one doesn't corrupt parsing of the other.
Seeding. Seed the exact array shape the cast expects ([['question' => ..., 'answer' => ...]]), not a serialized string, and re-run your cast/DTO in a test to prove round-tripping.
min()/max() and addButtonText() are UI affordances. min/max gate the add/remove buttons client-side; they are not validation. Pair with array|max:N rules and a hard check in the cast if the data matters.'array', not AsArrayObject, for the cast. AsArrayObject gives you an ArrayObject that round-trips awkwardly through Nova's field serialisation and JSON encoding. 'array' is boring and correct here; put object-ish behaviour in the DTO instead.artisan tinker — all of them can put malformed JSON into the column. The cast/DTO plus model-level validation is the only defence; Nova-declared rules do nothing for them.File/Image columns inside a JSON row mean orphaned uploads, broken previews, and awkward deletion semantics. Use a real relationship for anything with a file or its own lifecycle.max(20–50)), document the cap, and smoke-test at that count. Large payloads show up first as slow saves and eventually as truncated writes/timeouts.->searchable() or ->sortable() to this field and expect sane behaviour; filtering on nested values means JSON path queries (whereJsonContains, ->questions->0->answer) which are index-hostile and DB-specific. If it's in a hot filter path, denormalise into a real column or a search index.default() is a trap. A default array on the field means every new record starts with prefilled rows that editors must delete. Prefer an empty array and a good addButtonText().composer.json constraints rather than trusting the README badge.resolveUsing/fillUsing on the field or cast/DTO for data shaping; (2) a wrapper class in your app that returns the configured SimpleRepeatable; (3) only then fork. MIT means forking is available, but it makes the fork your upgrade problem forever — decide that deliberately.$fillable (or $guarded is open), cast is declared, field attribute matches the column name, and the resource's fields() isn't overridden by a FieldsForCreate/FieldsForUpdate variant you forgot about.How can I help you explore Laravel packages today?