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 Repeatable Fields Laravel Package

fourstacks/nova-repeatable-fields

A Laravel Nova field package for creating repeatable groups of fields in your resources. Add, remove, and reorder rows of custom field sets, store structured JSON data, and manage complex repeatable content directly within Nova forms.

View on GitHub
Deep Wiki
Context7

Getting Started

Confirm you actually have Nova first. This is not a standalone Laravel package — it's a Nova field class. No Nova licence, no install, no use case. If your admin layer is Filament, Backpack, or hand-rolled Blade, this package scores zero and the question is moot.

Pin the version, don't float it. The repo is archived, last release 2019-06-10. Treat the constraint as a lockfile entry, not a semver range:

composer require fourstacks/nova-repeatable-fields:1.3.0

Add a comment in the commit or composer.json explaining why it's pinned, because the next developer will assume it's an oversight.

There is no table to create — just a JSON column. The package stores rows as a JSON blob on the parent model:

// migration
$table->json('opening_hours')->nullable();

// model
protected $casts = ['opening_hours' => 'array'];

Attach it to a resource and compose subfields. Each addField() call describes one column inside every repeated row:

use Fourstacks\NovaRepeatableFields\Repeatable;

Repeatable::make('Opening Hours', 'opening_hours')
    ->addField([
        'name'     => 'day',
        'label'    => 'Day',
        'type'     => 'select',
        'options'  => ['mon' => 'Monday', 'tue' => 'Tuesday'],
        'required' => true,
    ])
    ->addField([
        'name'        => 'open',
        'label'       => 'Opens',
        'type'        => 'text',
        'placeholder' => '09:00',
    ])
    ->addField([
        'name'        => 'close',
        'label'       => 'Closes',
        'type'        => 'text',
        'placeholder' => '17:00',
    ])
    ->initialRows(1)   // start with one empty row
    ->heading('Day');  // v1.3.0+ → "Day 1", "Day 2", ...

Where to look first. src/Repeatable.php is the whole package on the PHP side. Read it before you write a resource. It tells you exactly which keys addField() accepts (name, label, type, placeholder, required, options), how initialRows() / heading() / button-label options are consumed, and how the value is hydrated and serialized. The README is thin and the package is unmaintained — the source is the documentation.

Pick a low-stakes pilot. Opening hours, an FAQ list, a set of contact methods — something admin-facing with four or five subfields so you actually exercise the row layout. Do not pilot on line items or addresses, which carry reporting and cross-record query implications (see Gotchas).

First sanity check. Save two rows, reload the form, and inspect the raw column with SELECT opening_hours FROM .... If it looks like {"0": {...}, "1": {...}} rather than [{...}, {...}], you already have the classic bug — see below.

Implementation Patterns

Validation lives on the parent model, not the field. The field is a UI widget over a JSON blob; it enforces nothing. Use wildcard rules and readable attribute names:

public function rules(): array
{
    return [
        'opening_hours'          => ['nullable', 'array', 'max:20'],
        'opening_hours.*.day'    => ['required', 'in:mon,tue,wed,thu,fri,sat,sun'],
        'opening_hours.*.open'   => ['required', 'date_format:H:i'],
        'opening_hours.*.close'  => ['required', 'date_format:H:i', 'after:opening_hours.*.open'],
    ];
}

public function attributeNames(): array
{
    return [
        'opening_hours.*.open'  => 'open time',
        'opening_hours.*.close' => 'close time',
    ];
}

The max:20 on the array is deliberate — cap the row count in validation, because there is no pagination or lazy loading in the UI.

Normalize on save — every single time. Deleted rows leave null holes and reorders can produce non-sequential keys. This is the number-one bug report on this package:

protected static function booted(): void
{
    static::saving(function (Model $model) {
        $model->opening_hours = collect($model->opening_hours ?? [])
            ->filter(fn ($row) => is_array($row) && collect($row)->filter(fn ($v) => filled($v))->isNotEmpty())
            ->values()   // ← the critical line
            ->all();
    });
}

->values() is what turns [0 => ..., 2 => ...] back into a JSON array instead of a JSON object. Without it, the front-end can store a value it can't render back, and users report "all my rows disappeared."

Filter only fully-empty rows in prepareForValidation. Nova repopulates from the request on validation failure; if you strip partially-filled rows, you delete the row the user was mid-way through typing:

protected function prepareForValidation($attributes)
{
    $attributes['opening_hours'] = array_values(array_filter(
        $attributes['opening_hours'] ?? [],
        fn ($row) => is_array($row) && collect($row)->contains(fn ($v) => filled($v))
    ));

    return $attributes;
}

Derive, don't persist. Totals, weekly hours, "computed" line-item amounts — all of these go stale the moment a row is reordered and the parent isn't resaved. Keep them as accessors:

public function getWeeklyHoursAttribute(): float
{
    return collect($this->opening_hours ?? [])
        ->filter(fn ($r) => filled($r['open'] ?? null) && filled($r['close'] ?? null))
        ->sum(fn ($r) => $this->durationInHours($r['open'], $r['close']));
}

Wrap access behind a typed value object if the shape is load-bearing. If three other classes read this column, hide the array behind a small DTO or a RepeatableCollection of OpeningHour objects. The raw JSON blob should not leak beyond the model.

Migrating from a child table? Write the reverse migration first. Treat it as a one-way content migration: map rows into a scratch JSON column via a console command, compare counts and sample values against the original table, then swap the column name in a second deploy. Do not do it in one go.

Testing pattern. HTTP-level tests won't catch add/remove/reorder because those happen in the Vue component. Minimum test coverage:

  • Feature test: POST the resource update endpoint with three rows, assert the persisted array is sequential and matches.
  • Feature test: POST with a null hole in the middle ([0 => ..., 2 => ...]) and assert the model's saving hook collapses it to [0 => ..., 1 => ...].
  • Feature test: POST with all-empty rows and assert they're filtered out.
  • One Dusk/browser test for the add-remove-reorder interaction if you have Nova browser testing set up — this is the only way to prove the Vue side works.

Integration tips. If you expose the same data elsewhere (API, PDF, email), read it through the model cast, never by hitting the JSON column directly. And if the same repeater shape appears on two resources, extract the Repeatable definition into a small builder method or a trait so the field definitions don't drift.

Gotchas and Tips

The archival status is the headline, not a footnote. Last release June 2019, zero dependents, no maintainer. Every Nova major is an unbounded break with no upstream fix — the Vue 2 → 3 transition is the concrete example. Before adopting, check the Nova major you run. If Nova ships a native repeater in your version, this package has no justification. Otherwise treat adoption as a fork decision: MIT means you can copy src/ and resources/js/ into your own namespace, tag it, and name an owner. Do not leave it as an unowned third-party dependency in composer.json.

Null holes are the number-one bug. Concretely: [0 => [...], 2 => [...]] serializes to {"0": {...}, "2": {...}} in the JSON column, and the front-end won't render it back as rows. Always array_values(). When something looks wrong, dd($model->getRawOriginal('opening_hours')) distinguishes storage corruption from rendering corruption before you touch any front-end code.

Heading numbers are cosmetic and client-side. ->heading('Day') renders "Day 1", "Day 2" from a counter scoped to the field instance. It renumbers after add/remove/reorder and corresponds to nothing stored. Never build anchors, exports, user-facing copy, or cross-references on those numbers. Also verify the markup — if it renders as a <div> rather than a heading element, and accessibility is a requirement, you'll need to supplement it.

Field-type coverage is incomplete. The type key maps to a fixed set of Nova field types in the Vue component. BelongsTo, File, Image, anything with async search or its own modal, and anything requiring server-side lookup are the usual failures. Test every type you use, one resource at a time — don't assume "it's a Nova field, it'll work."

No reactive field dependencies. There is no dependsOn, no hideWhen, no conditional logic inside a row. If your form needs "show the close time only if the day is selected," you're outside this package's capability. Use a child relationship instead.

Querying the blob degrades fast. "Every record with a Monday opening row" is a JSON path query — awkward in MySQL 5.7, borderline acceptable in 8.0+ with generated columns and indexes. If the data will ever need cross-record search, aggregation, reporting, or CSV export, keep it in a real child table. This is a data-model judgement, not a package defect, but it will get attributed to the package when reporting gets slow. Decide deliberately, up front.

Large row counts are a DOM and payload problem. No virtual scrolling, no pagination. Beyond roughly 30–50 rows the form gets sluggish and the save payload gets fat. Enforce a cap in validation (max:N on the array) with a clear message.

Debugging playbook, in order:

  1. Open the network tab on save. Read the raw opening_hours request payload. This instantly tells you whether the problem is front-end (rows never submitted) or back-end (normalization, validation, cast).
  2. On the server, dd($model->getRawOriginal('opening_hours')) to see pre-cast storage versus the cast array.
  3. If the field doesn't appear at all, run npm run dev (or prod) inside the Nova directory — Nova assets are compiled, and a stale build is the usual culprit.
  4. If a specific subfield type misbehaves, isolate it by removing every other addField() and adding them back one at a time.

Removing the field later leaves an orphan column. Decide its disposition before you ship: drop it, or keep it read-only behind a migration note. Either is fine — forgetting is what bites.

Write a one-page internal field guide covering: column type and cast, the wildcard validation rules, the normalizer in booted(), the row cap, the supported subfield types, and the fork owner. The package is trivial to use and easy to misuse, and every failure mode (vanished rows, stale totals, missing validation, unsupported subfield type) is something the next developer will hit within a week.

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.
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle
glavweb/extra-bundle