Filament dynamic field repeater and runtime renderer
Let admins define custom form fields in a Filament 4 repeater with type-aware options, then render those definitions as real form inputs.
FilamentFormsfilamentrepeaterdynamic-formbuilder
Code
<?php
namespace App\Filament\Support;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Str;
/** The editor: put FieldDefinitions::repeater() in a resource form. Cast the column to array. */
class FieldDefinitions
{
public const TYPES = [
'text' => 'Short text',
'number' => 'Number',
'select' => 'Dropdown',
'checkbox' => 'Checkbox',
];
public static function repeater(string $name = 'fields'): Repeater
{
return Repeater::make($name)
->columns(2)
->collapsible()
->cloneable()
->reorderableWithButtons()
->minItems(1)
->maxItems(30)
->addActionLabel('Add field')
->itemLabel(fn (array $state): ?string => $state['label'] ?? null)
->schema([
TextInput::make('label')
->required()
->live(onBlur: true)
// Generate the storage key once, from the label.
->afterStateUpdated(function (Get $get, Set $set, ?string $state) {
if (blank($get('key'))) {
$set('key', Str::snake((string) $state));
}
}),
TextInput::make('key')->required()->alphaDash()->distinct(),
Select::make('type')->options(self::TYPES)->default('text')->required()->live(),
Toggle::make('required')->inline(false),
// Options only make sense for dropdowns.
TagsInput::make('options')
->columnSpanFull()
->visible(fn (Get $get): bool => $get('type') === 'select')
->required(fn (Get $get): bool => $get('type') === 'select'),
// Numeric bounds only for numbers.
TextInput::make('min')->numeric()->visible(fn (Get $get): bool => $get('type') === 'number'),
TextInput::make('max')->numeric()->visible(fn (Get $get): bool => $get('type') === 'number'),
]);
}
}
<?php
namespace App\Filament\Support;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
/** The renderer: turn the saved definitions into real inputs, e.g. in a Wizard step or a public form page. */
class DynamicFields
{
/** @param array<int, array<string, mixed>> $definitions
* @return array<int, \Filament\Forms\Components\Field> */
public static function components(array $definitions, string $statePath = 'answers'): array
{
return collect($definitions)->map(function (array $field) use ($statePath) {
$name = "$statePath.{$field['key']}";
$component = match ($field['type']) {
'number' => TextInput::make($name)->numeric()
->minValue($field['min'] ?? null)
->maxValue($field['max'] ?? null),
'select' => Select::make($name)
->options(array_combine($field['options'] ?? [], $field['options'] ?? [])),
'checkbox' => Checkbox::make($name),
default => TextInput::make($name)->maxLength(255),
};
return $component
->label($field['label'])
->required((bool) ($field['required'] ?? false));
})->all();
}
}
Needs: filament/filament@^4.0
How it works
What it solves
Form, survey and intake builders let non-developers define which inputs a form has. That needs an editor for field definitions and a renderer that turns those definitions back into live inputs. This snippet shows both halves in Filament 4.
How it works
FieldDefinitions::repeater() returns a Repeater whose rows each describe one field: label, storage key, type, required flag and type-specific settings. The repeater is collapsible, reorderable with buttons and clonable, and itemLabel() shows the label as the row title.
Type awareness comes from live() on the type select plus visible() closures reading sibling state through Get. Choosing "Dropdown" reveals a required tags input for the choices, and "Number" reveals min and max. afterStateUpdated on the label fills the key with a snake-case version, but only while the key is blank, so manual edits stick. distinct() stops duplicate keys within the list.
DynamicFields::components() is the other half. It maps each saved definition to a real component with match, applies the label and required rule, and namespaces the state under answers.<key>.
Adapting it
Store the repeater in a JSON column cast to array. Add more types (date, textarea) to both TYPES and the match. For public rendering, reuse the same definitions to build Laravel validation rules.
Pitfalls
- Keys are part of your data contract, renaming one orphans old answers.
- Validate definitions again on the server; never trust a saved
typestring when generating rules. live()makes a request per change, uselive(onBlur: true)on text inputs.
How to use it
- Add a JSON column (for example
fields) to your form model and cast it toarray. - Use
FieldDefinitions::repeater()inside the resource form schema. - To collect answers, call
DynamicFields::components($form->fields)in a schema (a wizard step, a page form, or a public Livewire component). - Store answers as JSON keyed by each field key.
- Extend the
TYPESconstant and the renderermatchtogether.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.