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

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire up in four steps.

  1. 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.
  2. Add the storage yourself — the package ships no migration:
Schema::table('pages', function (Blueprint $table) {
    $table->json('faq_rows')->nullable();
});
  1. Cast it on the model so Eloquent hands you arrays rather than strings:
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.

  1. Declare the field in your resource's 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/.

Implementation Patterns

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.

Gotchas and Tips

  • 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.
  • JSON key = field attribute, and renaming a label renames the key. Non-negotiable rule: always pass the second argument. When you must rename, add both keys to the reading DTO for a release, backfill with an idempotent artisan command, then drop the old key.
  • Use '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.
  • Schema drift is silent. Existing records with a different shape render blank sub-fields or throw in the Vue component, and nothing at the DB level catches it. Keep a fixture of an old-shape row in your test suite and assert the DTO normalises it.
  • Non-Nova writers bypass everything. Seeders, imports, API endpoints, 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.
  • Don't nest repeatables and don't put file uploads inside rows. 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.
  • Row count is a real ceiling. Every row is rendered by the Vue component; form load and save payload both scale linearly. Cap it (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.
  • Not queryable, not searchable, not sortable. Don't add ->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().
  • Pin the package in lockstep with your Nova major. Upgrade Nova and this field in the same branch, in staging, with a smoke test that exercises your maximum row count and every sub-field type in use. A Nova major will break third-party field JS, and this is a single-maintainer package with no SLA — verify composer.json constraints rather than trusting the README badge.
  • Extension points, in order of preference: (1) 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.
  • Debugging a field that "won't save": check in this order — column exists and is nullable, column is in $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.
  • Test the DTO, not the Vue component. Nova field UI testing is expensive and brittle; almost all the real risk here is in shape handling and validation, which are plain PHP and cheap to cover.
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.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console