tienvx/ux-collection-js
tienvx/ux-collection-js is a lightweight Laravel/PHP package for dynamic form collections. It makes it easy to add, remove, reorder, and manage repeatable fields or list items in the browser with minimal JavaScript and simple, Blade-friendly integration.
composer require tienvx/ux-collection-js will pull the code, but in a Laravel app you are really interested in the Stimulus controller and any helpers shipped under the package's assets/ directory. Copy that controller into resources/js/controllers/ (e.g. collection_controller.js) and register it in your bootstrap.js (import Collection from './controllers/collection_controller'; application.register('collection', Collection)).assets/ first, then the demo. The package is thin; the README documents the Stimulus controller name, its data-* values, and the expected DOM contract (add button, remove button, prototype/template, min/max). The demo templates show what a "collection" form looks like — translate that to Blade.<template> (the "prototype") containing one blank row whose field names use a placeholder index. The controller clones the template on "Add", removes on "Remove", and optionally enforces min/max.<div data-controller="collection"
data-collection-min-value="1"
data-collection-max-value="10">
<div data-collection-target="container">
@foreach (old('items', $items ?? []) as $i => $item)
@include('partials.item', ['index' => $i, 'item' => $item])
@endforeach
</div>
<template data-collection-target="prototype">
@include('partials.item', ['index' => '__index__', 'item' => null])
</template>
<button type="button" data-collection-target="add">Add line</button>
</div>
__index__ or whatever the controller expects) in name, id, for, and any data-* references inside the partial. In the partial, derive everything from a single $index variable so replacement is a single string swap, not a regex over the whole fragment.this.indexValue) initialized to count(existing rows); increment on every add, and never reuse indices. Reusing children.length breaks after deletions and desynchronizes old() re-renders from PHP validation.items[0][description], items[0][qty] — and validate it in the controller: 'items' => ['array','max:10'], 'items.*.qty' => ['required','integer','min:1']. The JS max/min is UX sugar; the server rules are the source of truth.FormRequest, pass old('items', $items) back into the same partial loop using $loop->index (or explicit keys) so the indices the browser sent are the indices Blade re-renders. Any mismatch shows up as "field silently disappears" bugs.<x-collection> Blade component that owns: the wrapper data-controller, the existing rows, the <template>, and add/remove buttons. Pass min, max, sortable, and the partial name as props — your controllers and views then never touch Stimulus attributes directly.wire:click for add/remove and skip this package's DOM cloning; mixing both means cloned nodes are invisible to Livewire's model and won't be dehydrated. If you must mix, hook Livewire.hook('morph.updated', ...) to re-scan for add/remove triggers, since the controller's connect() will not rerun on morph.POST "add one row" route (?rows=N or a _rows hidden input) so the form works without JS and so you have a server-side reproduction when debugging the JS.items on submit before validation, or store an explicit position field, otherwise reordering in the browser silently doesn't change anything.assets/controllers.json auto-registration does not exist in Laravel — you register the controller yourself, and you ignore anything under the package's src/ unless you find a useful reference implementation.this.element, this.dispatch(), or older CustomEvent patterns, and pin @hotwired/stimulus accordingly. If connect() never fires, 99% of the time the controller isn't registered or the data-controller name doesn't match the application.register() identifier.html.replace(/__index__/g, i) is fine; naive element.innerHTML.replace(token, i) on the whole wrapper is not — it can hit tokens in unrelated places and produces indices inside IDs that collide with existing rows. Restrict replacement to the cloned <template> fragment.old() if the request fails afterwards; on re-render you may resurface deleted rows. Either submit the removed indices in a removed[] field and filter server-side, or accept the resurface as a known limitation and document it.id/for collisions. Cloned rows must get unique IDs, or label clicks focus the first row's input. Since you're substituting the index anyway, build IDs as items-{$index}-description in the partial — don't rely on a separate post-clone ID fixup script.package.json and build output before assuming import Collection from 'tienvx/ux-collection-js' works. Copying the controller source into your app is usually more reliable than consuming it as a node module.console.log in connect() → confirm registration; log the prototype target → confirm the <template> is found and content is non-null; log indexValue before/after add; then inspect the submitted payload in $request->all() to confirm names line up with your validation rules.wire:click, @entangle) or Alpine (x-for, x-ref) covers add/remove collections with better docs and no cross-framework glue. Use this package when you specifically want the Symfony CollectionType prototype/substitution semantics (min/max, remove buttons per row, optional sortable) in a server-rendered Blade form with vanilla Stimulus — otherwise skip it.data-* options (add/remove selectors, prototype target, min/max, placeholder) are the API. If you need custom behavior, copy the controller into your app and modify it rather than patching node_modules or fighting the upstream release cadence.How can I help you explore Laravel packages today?