Veröffentlicht am
8.6.2026
Einfache Formulargeneratoren in Laravel bauen
Ich entwickele für unsere Formel 1 Liga ein einfaches Managementsystem in Laravel und dort fiel mir auf, dass sich viele Formulare grundsätzlich wiederholen und sich die Blade-Dateien dadurch ziemlich aufblähen. Mein Gedankengang war dabei wie folgt: Wenn man schon Tabellen einfach dynamisch mit der @for-Direktive befüllen kann, wieso sollte das nicht auch mit Formularen gehen? Man könnte doch ganz einfach eine Klasse anlegen, wo das eigentliche Formularfeld angelegt und definiert wird. Man könnte in dieser Klasse z.B. festlegen, welchen Namen das Feld haben soll, welches Label das Feld haben soll und - ganz wichtig - welchen Typ es haben soll. Die Werte ließen sich ebenfalls in ein eigenes Feld speichern. Dank der praktischen Neuerungen von PHP 8.4 sollte das ja problemlos möglich sein. Ich habe hierzu die folgende Klasse erstellt:
<?php
namespace App\Components\Form;
use App\Enums\FieldType;
use Illuminate\Support\Collection;
/**
* Class Field
*
* Represents a dynamic form field used by the form generator.
* Supports fluent chaining for clean and intuitive configuration.
*
* @package App\Components\Form
*/
class Field
{
/**
* Field constructor using PHP 8.4 Asymmetric Visibility.
*
* Properties are publicly readable (public) but can only be modified
* within this class or via its fluent methods (private(set) / protected(set)).
*
* @param string $name The HTML name attribute of the field.
* @param FieldType $type The input type of the field.
* @param string $label The translated display label for the field.
* @param bool $required Determines whether the field is mandatory.
* @param int|null $max The maximum character length or numeric value constraint.
* @param string $width The Tailwind CSS grid column class for layout sizing.
* @param array|Collection $options The options list for select dropdowns or radio button groups.
* @param mixed $value The default or pre-filled value for the field.
*/
public function __construct(
readonly string $name,
readonly FieldType $type,
readonly string $label,
private(set) bool $required = false,
private(set) ?int $max = null,
private(set) string $width = 'md:col-span-2',
private(set) array|Collection $options = [],
private(set) mixed $value = null
) {}
/**
* Factory method to instantiate a new form field.
*
* @param string $name The HTML name attribute of the field.
* @param FieldType $type The strongly-typed input type of the field.
* @param string $label The display label for the field.
* @return self
*/
public static function make(string $name, FieldType $type, string $label): self
{
return new self($name, $type, $label);
}
/**
* Sets whether the field is required to be filled out.
*
* @param bool $required True if the field should be mandatory.
* @return self
*/
public function required(bool $required = true): self
{
$this->required = $required;
return $this;
}
/**
* Sets the maximum length (maxlength) or max value constraint for the field.
*
* @param int $max Maximum character length or limit.
* @return self
*/
public function max(int $max): self
{
$this->max = $max;
return $this;
}
/**
* Determines the visual width of the field within the Tailwind grid layout.
*
* @param string $width The grid column span class (e.g., 'md:col-span-1').
* @return self
*/
public function width(string $width): self
{
$this->width = $width;
return $this;
}
/**
* Sets the available options for selection elements like select dropdowns or radio groups.
*
* @param array|Collection $options An associative array or collection formatted as [value => label].
* @return self
*/
public function options(array|Collection $options): self
{
$this->options = $options;
return $this;
}
/**
* Sets the pre-filled value for the field (used in edit forms).
*
* @param mixed $value The value to pre-fill.
* @return self
*/
public function value(mixed $value): self
{
$this->value = $value;
return $this;
}
}
Im Konstruktor ab Zeile 33 sieht man, dass die ersten drei Attribute auf readonly gesetzt wurden und damit nur bei der Initialisierung gesetzt werden können. Eine Änderung ist daher nicht mehr möglich. Bei den restlichen Attributen wurde die neue private(set) Syntax verwendet, womit die Lese- und Schreibrechte völlig unabhängig voneinander gesteuert werden können:
- Lesen (
public): Da vorprivate(set)kein explizites Leserecht steht, gilt das standardmäßigepublic. Das heißt, dass man von überall im Code direkt auf das Attribut ohne einen Getter zugreifen kann. - Schreiben (
private(set)): Das Ändern eines Attributes wie z.B.$field->name = "driver_name"ist nur innerhalb der Klasse selbst erlaubt. Wenn du versuchst, den Wert des Attributs von außerhalb zu ändern, wirft PHP sofort einen Fehler.
Diese Änderung wird mit so genannten Fluent Methods ermöglicht, wie man sie z.B. in Zeile 75 für das Attribut max sehen kann.
Das static in der Methode make in Zeile 52 sorgt dafür, dass das Formularfeld nicht klassisch mit new erzeugt werden muss. Außerdem wird damit die Verkettung von Methoden mit dem -> ermöglicht, wie man sie eben häufig bei Laravel vorfindet. Hier habe ich mich einfach dem Framework angepasst, da das die Lesbarkeit erhöht.
Formulare erzeugen und anzeigen
Die Formulare werden nun im Controller in einer entsprechenden Funktion ganz einfach erzeugt…
/**
* Build the array of blank form fields for the league creation view.
*
* @param SupportCollection<int, string> $games Plucked collection of game names keyed by their IDs.
* @return array<Field> List of configured form field components.
*/
private function getCreateFields(SupportCollection $games): array
{
return [
Field::make('name', FieldType::TEXT, __('leagues.label.name'))
->required()
->max(64),
Field::make('started_at', FieldType::DATE, __('leagues.label.start_date'))
->required()
->width('md:col-span-1'),
Field::make('finished_at', FieldType::DATE, __('leagues.label.end_date'))
->required()
->width('md:col-span-1'),
Field::make('game_id', FieldType::SELECT, __('leagues.label.game'))
->required()
->options($games),
Field::make('is_equal_performance', FieldType::RADIO, __('leagues.label.performance'))
->width('md:col-span-2')
->options([
1 => __('leagues.option.equal'),
0 => __('leagues.option.realistic')
]),
];
}
und an der View ganz einfach übergeben:
/**
* Show the form for creating a new league.
*
* @return View
*/
public function create(): View
{
$games = Game::pluck('name', 'id');
$fields = $this->getCreateFields($games);
return view('dashboard.leagues.create', compact('games', 'fields'));
}
In der View selber befindet sich in dem Formular dann nur noch die Referenz auf den dynamischen Formulargenerator unter der @csrf-Direktive:
<form action="{{ route('dashboard.leagues.store') }}" method="POST" class="p-6 md:p-10 space-y-6">
@csrf
<x-form.generator :fields="$fields" />
<div
class="pt-6 border-t border-gray-100 flex flex-col-reverse sm:flex-row sm:items-center sm:justify-end space-y-4 space-y-reverse sm:space-y-0 sm:space-x-4">
<a href="{{ route('dashboard.leagues.index') }}"
class="w-full sm:w-auto text-center px-6 py-3.5 text-xs font-bold uppercase tracking-widest text-gray-500 hover:text-gray-700 transition-colors">
{{ __('general.cancel') }}
</a>
<button type="submit"
class="w-full sm:w-auto inline-flex items-center justify-center px-8 py-4 bg-gray-950 text-white rounded-xl text-xs font-black uppercase tracking-widest hover:bg-red-600 transform transition-all active:scale-95 shadow-lg shadow-gray-100">
<x-heroicon-o-check-circle class="w-5 h-5 mr-2" />
{{ __('leagues.title.create') }}
</button>
</div>
</form>
Die zuvor erzeugten Formularfelder werden hier ganz einfach via :fields="$fields" übergeben. Fertig! Mein mit Tailwind gestylter Formulargenerator sieht übrigens so aus:
@props(['fields'])
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@foreach($fields as $field)
<div class="{{ $field->width }}">
<label for="{{ $field->name }}"
class="block text-xs font-black uppercase tracking-wider
text-gray-700 {{ $field->type->value === 'radio' ? 'mb-3' : 'mb-2' }}">
{{ $field->label }} @if($field->required)<span class="text-red-500">*</span>@endif
</label>
@if($field->type->value === 'textarea')
<textarea
id="{{ $field->name }}"
name="{{ $field->name }}"
rows="3"
@if($field->max) maxlength="{{ $field->max }}" @endif
@if($field->required) required @endif
class="w-full rounded-xl border-gray-200 shadow-sm text-sm focus:border-red-500
focus:ring-red-500 py-3 px-4 border transition-colors
@error($field->name) border-red-500 @enderror"
>{{ old($field->name, $field->value) }}</textarea>
@elseif($field->type->value === 'select')
<select
id="{{ $field->name }}"
name="{{ $field->name }}"
@if($field->required) required @endif
class="w-full rounded-xl border-gray-200 shadow-sm text-sm focus:border-red-500
focus:ring-red-500 py-3 px-4 border transition-colors
@error($field->name) border-red-500 @enderror"
>
@foreach($field->options as $id => $name)
<option value="{{ $id }}" {{ old($field->name, $field->value) == $id ? 'selected' : '' }}>
{{ $name }}
</option>
@endforeach
</select>
@elseif($field->type->value === 'radio')
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@foreach($field->options as $value => $label)
<label class="relative flex items-center p-4 bg-gray-50 border border-gray-200
rounded-xl cursor-pointer hover:bg-gray-100/50 transition-colors group">
<input
type="radio"
name="{{ $field->name }}"
value="{{ $value }}"
class="text-gray-950 focus:ring-gray-950 h-4 w-4 border-gray-300"
{{ old($field->name, $field->value ?? 1) == $value ? 'checked' : '' }}
>
<span class="ml-3 text-sm font-bold text-gray-900 uppercase tracking-tight">
{{ $label }}
</span>
</label>
@endforeach
</div>
@else
<input
type="{{ $field->type->value }}"
id="{{ $field->name }}"
name="{{ $field->name }}"
value="{{ old($field->name, $field->value) }}"
@if($field->max) maxlength="{{ $field->max }}" @endif
@if($field->required) required @endif
class="w-full rounded-xl border-gray-200 shadow-sm text-sm focus:border-red-500
focus:ring-red-500 py-3 px-4 border transition-colors
@error($field->name) border-red-500 @enderror"
>
@endif
@error($field->name)
<p class="text-red-600 text-xs mt-1 font-medium">{{ $message }}</p>
@enderror
</div>
@endforeach
</div>
Somit können Formulare ganz bequem im Controller erzeugt werden. Natürlich sind hier nicht alle Fälle abgedeckt und der ständige Wechsel zwischen @if-@elseif-@else ist gerade für die Wartbarkeit nicht unerheblich. Daher habe ich mich bemüht, nur die einfachen Fälle für primitive Formulare abzudecken. Bei komplexeren Formularen empfiehlt sich daher die Aufspaltung in mehrere Dateien oder man legt für jeden FieldType direkt eine eigene Blade-Komponente an und ruft diese dynamisch über den Typen auf, z.B. via <x-dynamic-component :component="'form.' . $field->type->value" />.