<x-widget.repeater>
Repeater Blade component for Laravel and Livewire
A list of rows of fields the person can add to, remove from and reorder: order lines, travellers, contacts. Rows are keyed, not numbered, so old input, validation errors and focus stay with the right row after every change. Any field works in a row, select and date pickers included. Keyboard reorderable, announced to screen readers, with a minimum and maximum number of rows.
php artisan bladewell:add repeater
- Also adds
- Button, Icon
- Used by
- Filter builder
- Works with
- Laravel 12 and 13, Livewire 3 and 4, Tailwind CSS 4.1+, PHP 8.3+
Usage
Validation
use Illuminate\Support\Facades\DB;
// Rows arrive keyed: a record's id, or "new-…" for a row added in the browser. Validate them with *, as any array.
// A row only arrives if one of its fields is sent, and a disabled field isn't: a row of nothing but unticked
// checkboxes or disabled fields needs a field that always is, or saving below deletes it.
$validated = $request->validate([
'lines' => ['required', 'array', 'min:1', 'max:10'],
// distinct: each product once. Its error is filed under the row's key, so it shows on that row.
'lines.*.product' => ['required', 'string', 'max:120', 'distinct'],
'lines.*.quantity' => ['required', 'integer', 'min:1'],
], attributes: [
// Without these, messages name the field by its key: "The lines.new-k3f9qx.product field is required."
'lines.*.product' => 'product',
'lines.*.quantity' => 'quantity',
]);
$isNew = fn (int|string $key): bool => str_starts_with((string) $key, 'new-');
$kept = array_filter(array_keys($validated['lines']), fn (int|string $key): bool => ! $isNew($key));
// In one transaction, so a key that isn't this order's (findOrFail) undoes the deletes before it too.
DB::transaction(function () use ($order, $validated, $kept, $isNew): void {
// Lines no longer sent were removed on the page.
$order->lines()->whereNotIn('id', $kept)->delete();
// The array is in the order the rows were on the page. A key is looked up through the order it belongs to, so a
// request can't edit someone else's line by sending its id.
$position = 0;
foreach ($validated['lines'] as $key => $line) {
$attributes = [...$line, 'position' => $position++];
$isNew($key)
? $order->lines()->create($attributes)
: $order->lines()->findOrFail($key)->update($attributes);
}
});
Livewire component
In a Livewire component the array property owns the rows. Key them as the plain form does, by a record's id or "new-…", so wire:model, errors and focus follow a row when it moves. Every method can be called from the browser with any key, so check what it's given, and keep the limits on the server too.
use Illuminate\Support\Str;
use Livewire\Component;
class EditOrder extends Component
{
public Order $order;
/** @var array<int|string, array{product: string, quantity: int}> */
public array $lines = [];
public function mount(Order $order): void
{
$this->order = $order;
$this->lines = $order->lines()->orderBy('position')->get()
->mapWithKeys(fn ($line) => [$line->id => $line->only('product', 'quantity')])->all();
}
public function addLine(): void
{
if (count($this->lines) < 10) {
$this->lines['new-'.Str::lower(Str::random(6))] = ['product' => '', 'quantity' => 1];
}
}
public function removeLine(string $key): void
{
if (count($this->lines) > 1) {
unset($this->lines[$key]);
}
}
// $direction is -1 (up) or 1 (down): the row swaps places with its neighbour.
public function moveLine(string $key, int $direction): void
{
$keys = array_keys($this->lines);
$from = array_search($key, array_map('strval', $keys), true);
$to = $from === false ? null : $from + $direction;
if ($to === null || ! isset($keys[$to])) {
return;
}
[$keys[$from], $keys[$to]] = [$keys[$to], $keys[$from]];
$this->lines = array_replace(array_flip($keys), $this->lines);
}
public function save(): void
{
// The validated lines, not $this->lines: the browser can set any public property, extra fields included, and
// validate() returns only the fields it has rules for.
$validated = $this->validate([
'lines' => ['required', 'array', 'min:1', 'max:10'],
'lines.*.product' => ['required', 'string', 'max:120', 'distinct'],
'lines.*.quantity' => ['required', 'integer', 'min:1'],
], attributes: [
'lines.*.product' => 'product',
'lines.*.quantity' => 'quantity',
]);
// Then save $validated['lines'] as in the validation snippet.
}
}
Livewire view
Its view: the actions name the component's methods, and each row's fields bind with wire:model under its key, no name needed. Rows come from the property, with no placeholder row: the component adds them. The script keeps focus on the row that moved, or where a removed one was, after each render, and reads the change out.
<form wire:submit="save">
<x-widget.repeater name="lines" label="Order lines" :min="1" :max="10" add-label="Add line" row-label="Line" add-action="addLine" remove-action="removeLine" move-action="moveLine">
@foreach ($lines as $key => $line)
<x-widget.repeater.row :key="$key">
<x-widget.text-input wire:model="lines.{{ $key }}.product" label="Product" required />
<x-widget.number wire:model="lines.{{ $key }}.quantity" label="Quantity" :decimals="0" :min="1" stepper />
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
<x-widget.button type="submit" class="mt-6">Save order</x-widget.button>
</form>
On the server
The repeater can't stop a request that skips it. Here's what to check on your server, and what to keep cheap.
Security
-
A request can send any keys and any number of rows. Cap them with
max:on the array, and look each existing key up through the record it belongs to ($order->lines()->findOrFail($key)), never as a bare id, or anyone could edit anyone's rows.OWASP: Insecure direct object references · Laravel: Validating arrays
Server cost
-
Every row is in the page and in the request, so keep lists to what a person edits by hand: a few dozen rows, a hundred or so at most, each with a few fields. Past that, or with a select or date picker in every row, page the records or edit them in a table instead.
max:caps what the server takes either way, and PHP'smax_input_vars(1000 by default) quietly drops fields past it: 200 rows of 5 fields already reach it.
Examples
These examples also use Text input, Number, Date picker and Select, which Repeater doesn't need on its own. To use them as they are:
php artisan bladewell:add repeater text-input number datepicker select
Order lines
Each row is keyed, by the record's id or by the key a new row gets in the browser, never by its position. Its fields are named lines[{key}][…], so old input and errors stay with the row after adding, removing or moving. After a failed submit the rows come from the old input, even none: with every row removed, the form sends no lines, and old('lines', $lines) would bring the saved ones back. The last pass of the loop, under the placeholder key, is the template new rows are copied from. Resetting the form puts its values back but keeps the rows added or removed since.
Pass
$lines
from your controller.
Show code Hide code
<form class="max-w-2xl">
<x-widget.repeater name="lines" label="Order lines" :min="1" :max="10" add-label="Add line" row-label="Line">
@foreach ((old() ? old('lines', []) : $lines) + ['__row__' => []] as $key => $line)
<x-widget.repeater.row :key="$key">
<x-widget.text-input name="lines[{{ $key }}][product]" label="Product" :value="$line['product'] ?? null" required />
<x-widget.number name="lines[{{ $key }}][quantity]" label="Quantity" :decimals="0" :min="1" :value="$line['quantity'] ?? 1" stepper />
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
</form>
Team invites
Invite several people at once, an email and a role a row. compact puts each row on one line with its buttons beside it; screen readers still hear "Remove Person 2". It starts with one blank row, so there's somewhere to type straight away.
Pass
$invites
from your controller.
Show code Hide code
<form class="max-w-2xl">
<x-widget.repeater name="invites" label="Invite your team" :min="1" :max="20" add-label="Add person" row-label="Person" compact>
@foreach ((old() ? old('invites', []) : $invites) + ['__row__' => []] as $key => $invite)
<x-widget.repeater.row :key="$key">
<x-widget.text-input name="invites[{{ $key }}][email]" type="email" label="Email" placeholder="[email protected]" :value="$invite['email'] ?? null" required />
<x-widget.select name="invites[{{ $key }}][role]" label="Role" :options="['member' => 'Member', 'admin' => 'Admin', 'viewer' => 'Viewer']" :value="$invite['role'] ?? 'member'" />
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
</form>
Invoice total
Your own script can follow the rows: here a total that keeps up as quantities and prices change, and as lines go, through the repeater:remove event. It's for the person filling the form in. The server works the total out again from the lines it gets, never from the page.
Pass
$items
from your controller.
Show code Hide code
@php($rows = old() ? old('items', []) : $items)
<form class="max-w-2xl" data-invoice>
<x-widget.repeater name="items" label="Invoice items" :min="1" :max="50" add-label="Add item" row-label="Item">
@foreach ($rows + ['__row__' => []] as $key => $item)
<x-widget.repeater.row :key="$key">
<x-widget.text-input name="items[{{ $key }}][description]" label="Description" :value="$item['description'] ?? null" required class="sm:col-span-2" />
<x-widget.number name="items[{{ $key }}][quantity]" label="Quantity" :decimals="0" :min="1" :value="$item['quantity'] ?? 1" stepper />
<x-widget.number name="items[{{ $key }}][price]" label="Unit price" prefix="$" :min="0" :value="$item['price'] ?? null" />
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
<p class="text-foreground border-line mt-6 flex justify-between border-t pt-4 text-base">
<span>Total</span>
<output data-invoice-total class="font-medium tabular-nums">${{ number_format(collect($rows)->sum(fn ($item) => (float) ($item['quantity'] ?? 0) * (float) ($item['price'] ?? 0)), 2) }}</output>
</p>
</form>
// The total is each line's quantity times its price, added up, worked out again whenever a field in the invoice changes.
document.addEventListener('input', (event) => {
const invoice = event.target.closest?.('[data-invoice]');
if (!invoice) {
return;
}
const total = [...invoice.querySelectorAll('[data-repeater-row]')].reduce((sum, row) => {
const quantity = Number(row.querySelector('[name$="[quantity]"]')?.value) || 0;
const price = Number(row.querySelector('[name$="[price]"]')?.value) || 0;
return sum + quantity * price;
}, 0);
// Written as the server writes it, number_format($total, 2): 1,140.00.
invoice.querySelector('[data-invoice-total]').textContent = `$${total.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`;
});
// A line removed changes the total too, without a field changing: ask for the same sum.
document.addEventListener('repeater:remove', (event) => {
event.target.dispatchEvent(new Event('input', { bubbles: true }));
});
With errors
After a failed submit, each error is filed under its row's key (lines.15.quantity), so it shows on that row wherever the row has moved. An error about the rows as a whole (lines: max) shows under them and goes once a row is added or removed.
Pass
$lines
from your controller.
Show code Hide code
<form class="max-w-2xl">
<x-widget.repeater name="lines" label="Order lines" :min="1" :max="3" add-label="Add line" row-label="Line">
@foreach ((old() ? old('lines', []) : $lines) + ['__row__' => []] as $key => $line)
<x-widget.repeater.row :key="$key">
<x-widget.text-input name="lines[{{ $key }}][product]" label="Product" :value="$line['product'] ?? null" required />
<x-widget.number name="lines[{{ $key }}][quantity]" label="Quantity" :decimals="0" :min="1" :value="$line['quantity'] ?? 1" stepper />
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
</form>
Custom fields
Pairs of a name and a value: product details, metadata, custom fields. Compact rows keep a long list short. On the server, distinct on the names stops the same one being sent twice. With every row removed, the empty slot says what the list is for.
Pass
$details
from your controller.
Show code Hide code
<form class="max-w-2xl">
<x-widget.repeater name="details" label="Product details" :max="30" add-label="Add detail" row-label="Detail" compact>
@foreach ((old() ? old('details', []) : $details) + ['__row__' => []] as $key => $detail)
<x-widget.repeater.row :key="$key">
<x-widget.text-input name="details[{{ $key }}][name]" label="Name" placeholder="Material" :value="$detail['name'] ?? null" required />
<x-widget.text-input name="details[{{ $key }}][value]" label="Value" placeholder="Solid oak" :value="$detail['value'] ?? null" required />
</x-widget.repeater.row>
@endforeach
<x-slot:empty>No details yet. Add material, size or anything else a buyer asks about.</x-slot:empty>
</x-widget.repeater>
</form>
Travellers
Any field works in a row, including those with their own script and popups: a select and a date picker here. A copied row gets its own ids, so every label, list and calendar points at the row it's in.
Pass
$travellers
from your controller.
Show code Hide code
<form class="max-w-2xl">
<x-widget.repeater name="travellers" label="Travellers" :min="1" :max="6" add-label="Add traveller" row-label="Traveller">
@foreach ((old() ? old('travellers', []) : $travellers) + ['__row__' => []] as $key => $traveller)
<x-widget.repeater.row :key="$key">
<x-widget.text-input name="travellers[{{ $key }}][name]" label="Full name" :value="$traveller['name'] ?? null" required />
<x-widget.datepicker name="travellers[{{ $key }}][born_on]" label="Date of birth" :value="$traveller['born_on'] ?? null" birthday />
<x-widget.select name="travellers[{{ $key }}][meal]" label="Meal" :options="['standard' => 'Standard', 'vegetarian' => 'Vegetarian', 'vegan' => 'Vegan', 'gluten_free' => 'Gluten free']" :value="$traveller['meal'] ?? 'standard'" />
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
</form>
Questions
A repeater inside a row: each question keeps its own list of answers, named under both keys, questions[{question}][answers][{answer}]. The inner one needs its own placeholder, and sets its own labels, so its rows don't take the question's. Adding a question copies an empty answer list under the new question's key.
Pass
$questions
from your controller.
Show code Hide code
<form class="max-w-2xl">
<x-widget.repeater name="questions" label="Questions" :min="1" :max="5" add-label="Add question" row-label="Question">
@foreach ((old() ? old('questions', []) : $questions) + ['__row__' => []] as $key => $question)
<x-widget.repeater.row :key="$key">
<x-widget.text-input name="questions[{{ $key }}][text]" label="Question" :value="$question['text'] ?? null" required class="sm:col-span-2" />
<x-widget.repeater name="questions[{{ $key }}][answers]" label="Answers" :max="6" add-label="Add answer" row-label="Answer" placeholder="__answer__" class="sm:col-span-2">
@foreach (($question['answers'] ?? []) + ['__answer__' => []] as $answerKey => $answer)
<x-widget.repeater.row :key="$answerKey">
<x-widget.text-input name="questions[{{ $key }}][answers][{{ $answerKey }}][text]" label="Answer" :value="$answer['text'] ?? null" required class="sm:col-span-2" />
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
</x-widget.repeater.row>
@endforeach
</x-widget.repeater>
</form>
Props
Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.
<x-widget.repeater>
| Prop | Default | Description |
|---|---|---|
| name | Required | The array the rows submit as, as their fields name it: "lines" for lines[{key}][product]. Errors for the array itself (lines: min, max, required) show under the rows. |
| label |
null
|
Shown above the rows, and the group's name for screen readers. |
| min |
0
|
The fewest rows: at this many, the remove buttons are disabled. Check it on the server too, with min: on the array. |
| max |
null
|
The most rows: at this many, the add button is disabled. Check it on the server too, with max: on the array. |
| add-label |
'Add row'
|
The add button's text. |
| row-label |
'Row'
|
What a row is called, numbered after it: "Row 1", "Line 2". Also starts each row's button names, "Remove Row 2". |
| remove-label |
'Remove'
|
The remove button's name, followed by the row's: "Remove Row 2". |
| move-up-label |
'Move up'
|
The move up button's name, followed by the row's. |
| move-down-label |
'Move down'
|
The move down button's name, followed by the row's. |
| added-message |
':row added'
|
Read out after adding a row. :row is the row's name, "Row 4". |
| removed-message |
':row removed'
|
Read out after removing a row. |
| moved-message |
':row moved to position :position'
|
Read out after moving a row. :row is its name before the move, :position where it is now. |
| compact |
false
|
Rows on one line, the buttons beside the fields and no title above them (screen readers still hear "Row 2"), for short rows: an email and a role, a key and a value. Fields take equal columns; wider screens fit more per line. |
| movable |
true
|
The move up and move down buttons. false for rows whose order means nothing, as a list of filters. |
| placeholder |
'__row__'
|
The key the template row is rendered with, replaced by a new one for each row added. A repeater inside another needs its own, e.g. "__option__". |
| error |
null
|
The error message(s) for the array. Defaults to the ones Laravel has for the name. |
| bag |
'default'
|
The error bag to read errors from. |
| add-action |
null
|
In a Livewire component: the method the add button calls, e.g. "addLine". With any of the three actions the component's array owns the rows, and the script only keeps focus, the buttons' states and the announcements right. A button whose action isn't set is left out: add-action alone gives rows that can't be removed or moved. |
| remove-action |
null
|
In a Livewire component: the method a remove button calls with the row's key, e.g. "removeLine". |
| move-action |
null
|
In a Livewire component: the method a move button calls with the row's key and -1 (up) or 1 (down), e.g. "moveLine". |
Slots
- <x-slot:empty>
- Shown while there are no rows: what the list is for, so the add button makes sense on its own.
<x-widget.repeater.row>
| Prop | Default | Description |
|---|---|---|
| key | Required | The row's key, as in its fields' names: lines[{key}][product]. An existing record's id, or the key a row added in the browser was given. The repeater's placeholder makes this the template new rows are copied from. |
Accessibility
All 7 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.
Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.
Source
What bladewell:add repeater writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from
the components it also adds and Field, and the theme and base CSS.
resources/views/components/widget/repeater/index.blade.php Show
resources/views/components/widget/repeater/row.blade.php Show
resources/js/widget/repeater/index.js Show
app/View/Widget/ElementIds.php Show
app/View/Widget/FormField.php Show