Skip to content
Bladewell
Guides

Build a Laravel form from your validation rules

Turn a Form Request into a Blade form whose fields fill in old input and show their own errors: names, limits and widgets from each rule, in a controller or a Livewire component.

php artisan bladewell:add text-input number select datepicker textarea checkbox file-upload button

Uses Text input, Number, Select, Date picker, Textarea, Checkbox, File upload and Button. New to Bladewell? Install it first.

Start from your rules

Your Form Request already says what every field is: its name, whether it's required, how long it can be, which values it takes. The form only has to say the same. Here's a booking with a few kinds of field:

app/Http/Requests/StoreBookingRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreBookingRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:120'],
            'email' => ['required', 'email'],
            'guests' => ['required', 'integer', 'min:1', 'max:12'],
            'room' => ['required', Rule::in(['standard', 'deluxe', 'suite'])],
            'arrival' => ['required', 'date', 'after:today'],
            'notes' => ['nullable', 'string', 'max:500'],
            'newsletter' => ['boolean'],
            'passport' => ['nullable', 'file', 'mimes:pdf,jpg,png', 'max:2048'],
        ];
    }
}

Name each field after its rule

Give each component the rule's key as its name, then copy the limits across: max:120 becomes maxlength="120", in:… becomes a select's :options, mimes becomes the upload's accept. Add required where the rules say required, and leave it off for nullable.

resources/views/bookings/create.blade.php
<form method="POST" action="{{ route('bookings.store') }}" enctype="multipart/form-data" class="grid gap-5">
    @csrf
    <x-widget.text-input name="name" label="Name" maxlength="120" required />
    <x-widget.text-input name="email" type="email" label="Email" required />
    <x-widget.number name="guests" label="Guests" :decimals="0" :min="1" :max="12" required />
    <x-widget.select name="room" label="Room" :options="['standard' => 'Standard', 'deluxe' => 'Deluxe', 'suite' => 'Suite']" required />
    <x-widget.datepicker name="arrival" label="Arrival" :min="now()->addDay()->toDateString()" :max="false" required />
    <x-widget.textarea name="notes" label="Notes" maxlength="500" counter />
    <x-widget.checkbox name="newsletter" label="Send me offers" unchecked-value="0" />
    <x-widget.file-upload name="passport" label="Passport scan" accept=".pdf,.jpg,.jpeg,.png" max-size="2048" />
    <x-widget.button type="submit">Book</x-widget.button>
</form>

The browser now stops most mistakes before the form is sent, and your rules still decide on the server. A file field needs enctype="multipart/form-data" on the form. A boolean checkbox takes unchecked-value="0", so unticking it sends a value too.

The installation guide's table lists the component and props for every common rule, including the traps: mimes:jpg accepts .jpeg files too, and a date picker allows nothing after today until you pass :max="false".

The controller

Nothing in it is about the form. Type-hint the Form Request and Laravel validates before store runs.

app/Http/Controllers/BookingController.php
namespace App\Http\Controllers;

use App\Http\Requests\StoreBookingRequest;
use App\Models\Booking;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class BookingController extends Controller
{
    public function create(): View
    {
        return view('bookings.create');
    }

    public function store(StoreBookingRequest $request): RedirectResponse
    {
        $booking = Booking::create([
            ...$request->safe()->except('passport'),
            // Stored on the default, private disk: a passport scan isn't for the public.
            'passport_path' => $request->file('passport')?->store('passports'),
        ]);

        return redirect()->route('bookings.show', $booking);
    }
}

Errors and old input, without a line of code

When validation fails, Laravel sends the visitor back with the errors and what they typed. Each component looks itself up by its name: it fills in the old value, shows its own error under the field, marks itself aria-invalid and links the message with aria-describedby, so screen readers announce it. There's no @error block and no old() call to write.

The messages come from the default error bag. For a form that validates into a named bag, as with validateWithBag('booking', …), add bag="booking" to its fields.

The edit form

Pass the saved value as :value. After a failed submit, old input still wins, so the visitor sees what they typed, not what was saved. Date pickers take a date object as well as a Y-m-d string.

resources/views/bookings/edit.blade.php
<form method="POST" action="{{ route('bookings.update', $booking) }}" class="grid gap-5">
    @csrf
    @method('PUT')
    <x-widget.text-input name="name" label="Name" maxlength="120" :value="$booking->name" required />
    <x-widget.number name="guests" label="Guests" :decimals="0" :min="1" :max="12" :value="$booking->guests" required />
    <x-widget.datepicker name="arrival" label="Arrival" :min="now()->addDay()->toDateString()" :max="false" :value="$booking->arrival" required />
    <x-widget.button type="submit">Save</x-widget.button>
</form>

In a Livewire component

Swap name for wire:model. The components read their errors from Livewire instead of the session, and fill in from the bound properties. They don't need Livewire, so the same component works in both kinds of form.

app/Livewire/BookingForm.php
namespace App\Livewire;

use App\Models\Booking;
use Livewire\Component;

class BookingForm extends Component
{
    public string $name = '';

    public string $email = '';

    public ?int $guests = null;

    public function save()
    {
        $validated = $this->validate([
            'name' => ['required', 'string', 'max:120'],
            'email' => ['required', 'email'],
            'guests' => ['required', 'integer', 'min:1', 'max:12'],
        ]);

        $booking = Booking::create($validated);

        return $this->redirectRoute('bookings.show', $booking);
    }

    public function render()
    {
        return view('livewire.booking-form');
    }
}
resources/views/livewire/booking-form.blade.php
<form wire:submit="save" class="grid gap-5">
    <x-widget.text-input wire:model="name" label="Name" maxlength="120" required />
    <x-widget.text-input wire:model="email" type="email" label="Email" required />
    <x-widget.number wire:model="guests" label="Guests" :decimals="0" :min="1" :max="12" required />
    <x-widget.button type="submit">Book</x-widget.button>
</form>