Filament v4 Repeater & Builder: Edit Nested, Repeatable Form Data

Master the Filament repeater field and Builder in v4: store rows in a JSON column or a HasMany relationship, read parent values in a row, then fix empty saves.

Steven Richardson
Steven Richardson
· 9 min read

Flat Filament forms are easy. Then a real model turns up — an invoice with a variable number of line items, a page built from stacked content blocks — and you need rows that repeat and blocks that differ. The two fields that handle this, the Repeater and the Builder, drop in fast and are surprisingly easy to misconfigure: data lands in the wrong place, parent values come back null inside a row, and relationship rows save empty.

Here's how I wire both up on current Filament v4 APIs — where the data actually lives, the one traversal rule that trips everyone, and why those nested rows go blank.

Add a Repeater backed by a JSON column#

Start with the simplest storage option: a single JSON column. The Repeater renders an array of identical sub-forms — ideal for invoice line items, key/value specs, or opening hours — and when you cast that column to array, Filament reads and writes the whole set as JSON with no extra table to manage.

Add the column in a migration:

// database/migrations/xxxx_add_items_to_invoices_table.php
Schema::table('invoices', function (Blueprint $table) {
    $table->json('items')->nullable();
});

Cast it to an array on the model so Filament hands you (and stores) a PHP array:

// app/Models/Invoice.php
protected function casts(): array
{
    return [
        'items' => 'array',
    ];
}

Then point a Repeater at that attribute. Each field inside ->schema() is a normal Filament form field — the same components you use everywhere else, so a row can hold anything from a custom Filament form field to a plain text input:

use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;

Repeater::make('items')
    ->schema([
        TextInput::make('description')->required()->columnSpan(2),
        TextInput::make('quantity')->numeric()->default(1)->required(),
        TextInput::make('unit_price')->numeric()->prefix('£')->required(),
    ])
    ->columns(4)
    ->defaultItems(1)

That's the whole pattern for self-contained data. The trade-off: the rows live as opaque JSON, so the database can't query or aggregate them. The moment you need that, move to a relationship.

Bind the Repeater to a HasMany relationship#

A JSON column is invisible to SQL — you can't filter, join, or sum individual rows. When each row needs to be its own record (a reportable InvoiceItem, separately validated, joinable in queries), back the Repeater with a HasMany relationship instead. Call ->relationship() and Filament hydrates the rows from the related table on load and persists each one as its own model on save.

// Invoice model: public function items(): HasMany { return $this->hasMany(InvoiceItem::class); }

Repeater::make('items')
    ->relationship() // uses the items() relationship; pass a name if it differs: ->relationship('lineItems')
    ->schema([
        TextInput::make('description')->required()->columnSpan(2),
        TextInput::make('quantity')->numeric()->default(1)->required(),
        TextInput::make('unit_price')->numeric()->required(),
    ])
    ->columns(4)

Drop the array cast when you switch — the data lives in invoice_items now, not a JSON column. On a create form, Filament saves the parent Invoice first and then writes the children automatically, so you don't manage the ordering yourself.

The classic gotcha lives here: the related model's columns must be mass-assignable. If description, quantity, and unit_price aren't in InvoiceItem's $fillable (or you haven't set $guarded = []), Filament happily creates a row with every attribute blank. That's the number-one cause of "my repeater saves empty rows."

Note that this only works cleanly for HasMany. A BelongsToMany with pivot data behaves differently — the Repeater writes to the related model, not the pivot — so reach for a many-to-many relation manager with pivot fields there. And if the child really wants its own full-page list and edit screens rather than inline rows, Filament v4 nested resources are the better model.

Read parent and sibling fields inside a row#

Inside a repeater row, $get() and $set() are scoped to the current item. That's exactly what you want for sibling fields, and exactly what surprises you when you reach for a field outside the repeater. A sibling in the same row is $get('field') — no prefix. A field on the parent form is two levels up the data tree: $get('../../field'). One ../ escapes the item; the second escapes the repeater's array of items.

Here's a per-row line total that reads two siblings and the form-level currency field above the repeater:

use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Utilities\Get;

Select::make('currency')
    ->options(['GBP' => '£ GBP', 'USD' => '$ USD', 'EUR' => '€ EUR'])
    ->default('GBP')
    ->required(),

Repeater::make('items')
    ->schema([
        TextInput::make('description')->required()->columnSpan(2),
        TextInput::make('quantity')->numeric()->default(1)->live(onBlur: true)->required(),
        TextInput::make('unit_price')->numeric()->live(onBlur: true)->required(),

        Placeholder::make('line_total')
            ->label('Line total')
            ->content(function (Get $get): string {
                $currency = $get('../../currency') ?? 'GBP';            // parent field, two levels up
                $total = (float) $get('quantity') * (float) $get('unit_price'); // siblings, same row
                return number_format($total, 2) . ' ' . $currency;
            }),
    ])
    ->columns(5),

The Get utility imports from Filament\Schemas\Components\Utilities in v4, not the old Filament\Forms namespace — if your IDE can't resolve it from an older snippet, that's why. Mark the inputs ->live() so the placeholder recomputes as you type. Get the traversal wrong — $get('../currency') with one ../ too few — and the closure reads the array container, returns null, and your total silently shows 0.00 with no error to chase. The same $get()/$set() rules drive dependent, reactive select fields inside a row.

For a grand total, put a Placeholder outside the repeater and sum the whole items array:

Placeholder::make('grand_total')
    ->content(function (Get $get): string {
        $sum = collect($get('items'))->sum(
            fn ($row): float => (float) ($row['quantity'] ?? 0) * (float) ($row['unit_price'] ?? 0),
        );

        return number_format($sum, 2) . ' ' . ($get('currency') ?? 'GBP');
    }),

Assemble content blocks with the Builder field#

When the rows aren't identical — a page made of headings, paragraphs, and images in any order — the Repeater is the wrong shape. The Builder stores an ordered list of typed blocks, each with its own schema, and saves them to an array-cast column as {type, data} objects you can render however you like.

use Filament\Forms\Components\Builder;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Textarea;

Builder::make('content')
    ->blocks([
        Builder\Block::make('heading')
            ->icon('heroicon-o-bars-3-bottom-left')
            ->schema([
                TextInput::make('text')->label('Heading')->required(),
                Select::make('level')->options(['h2' => 'H2', 'h3' => 'H3'])->default('h2'),
            ]),

        Builder\Block::make('paragraph')
            ->icon('heroicon-o-document-text')
            ->schema([
                Textarea::make('body')->required(),
            ]),

        Builder\Block::make('image')
            ->icon('heroicon-o-photo')
            ->schema([
                FileUpload::make('url')->image()->required(),
                TextInput::make('alt')->label('Alt text'),
            ]),
    ])
    ->collapsible()

Cast content to array exactly as you did for the JSON Repeater. A saved Builder value is a list like [{"type": "heading", "data": {"text": "...", "level": "h2"}}], which is trivial to loop over in a Blade view. The Builder shines when each block is a distinct, structured form. If your blocks are really rich text with inline mentions and merge tags, Filament's TipTap-based rich editor with custom blocks is the better tool for that job.

Add reordering, item labels, and limits#

The default Repeater is functional but bare: collapsed rows show no summary, nothing caps how many a user can add, and a relationship-backed repeater forgets its order between loads. A handful of chained methods make it production-ready.

Repeater::make('items')
    ->relationship()
    ->schema([ /* ... */ ])
    ->reorderable()                 // drag handles; reorderableWithButtons() for up/down buttons
    ->orderColumn('sort')           // persist drag order to a `sort` column on the related model
    ->collapsible()
    ->collapsed()                   // start each row collapsed
    ->cloneable()
    ->itemLabel(fn (array $state): ?string => $state['description'] ?? 'New item')
    ->minItems(1)
    ->maxItems(20)
    ->addActionLabel('Add line item')

itemLabel() reads the row's state to show a meaningful header when collapsed, instead of "Item 1, Item 2." minItems()/maxItems() enforce counts and add validation. One thing to watch on a relationship repeater: ->orderColumn('sort') only works if that sort column is in the related model's $fillable — the same mass-assignment rule that bit us earlier, in a different disguise.

Wrapping Up#

Pick storage first: an array-cast JSON column when the rows are only ever edited through this form, or ->relationship() when each row needs to be a queryable Eloquent record. Inside a row, remember the data tree — $get('field') for a sibling, $get('../../field') to climb out to the parent form. Reach for the Builder, not the Repeater, the moment the blocks stop being identical. And when rows save blank, check $fillable before anything else.

For the wider picture, the complete zero-to-production Filament v4 guide puts these forms in the context of a full panel. And because every ->relationship() row is a real Eloquent model, keep an eye on N+1 queries with Eloquent strict mode once those rows start loading in lists.

FAQ#

How do I store Filament repeater data in the database?

You have two options. Cast a single column to array on the model ('items' => 'array') and the Repeater serialises every row into that one JSON column — simplest, but the rows aren't queryable. Or call ->relationship() on the Repeater to persist each row as its own record in a related HasMany table, which lets you filter, join, and aggregate the rows in SQL. Choose JSON for self-contained data and a relationship when the rows need to be first-class records.

How do I access a parent field from inside a Filament repeater?

Inside a repeater item, $get() is scoped to the current row, so a parent form field is two levels up the data tree: $get('../../field_name'). The first ../ steps out of the current item and the second steps out of the repeater's array of items, landing you on the root form. A sibling field in the same row needs no prefix at all — just $get('field_name'). Using one ../ too few is the usual reason a parent value comes back null.

What is the difference between the Repeater and the Builder in Filament?

The Repeater edits an array of rows that all share one identical schema — line items, contacts, opening hours. The Builder edits an ordered list of blocks where each block type has its own distinct schema — a heading block, an image block, a quote block — so it's the right tool for page-builder-style content. Both can save to an array-cast JSON column, but the Builder also records each block's type alongside its data.

How do I save a Filament repeater to a relationship?

Define a HasMany relationship on the parent model, then call ->relationship() on the Repeater (optionally passing the relationship name if it doesn't match the field name). Filament loads the existing rows from that relationship and writes each row back as its own model when the form is submitted, saving the parent record first on create forms. Drop any array cast on the field, since the data now lives in the related table rather than a JSON column.

Why does my nested Filament repeater save empty values?

The most common cause is mass-assignment protection: the columns the repeater writes to aren't listed in the related model's $fillable array, so Filament creates the row but every attribute stays blank. Add the field names to $fillable (or set $guarded = []) on the related model. The same rule catches the sort column when you use ->orderColumn(), and a BelongsToMany relationship saving empty usually means it should be a HasMany or a relation manager instead.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.