Welcome to HowToShipIt — practical how-to guides for developers: code, AI tools, and servers, explained step by step.

Inertia.js Forms and Validation: The Complete Guide for Laravel + Vue 3

You already know how to build forms in a classic Laravel app: write the Blade, point the action at a route, and let validation errors fall out of the session on redirect. Inertia.js forms feel eerily familiar — and that is exactly the trap. They look like regular server-rendered forms, but the validation flow, the error plumbing, and three specific gotchas work just differently enough to eat your afternoons if you guess.

This guide walks through Inertia.js forms end to end with Laravel and Vue 3: the useForm helper, how server-side validation errors actually reach your components, file uploads, error bags, and the practical mistakes to avoid. Every behaviour here is taken from the current Inertia.js documentation and verified on 5 October 2026.

How Inertia.js Forms Actually Work

Forget everything you know about catching 422 responses in a catch block. Inertia never receives 422 responses. Instead, it behaves like a classic full-page form submission — without the full-page reload.

The flow goes like this:

  1. You submit the form through Inertia (the <Form> component, the useForm helper, or a manual router call).
  2. Laravel runs your validation. If it fails, Laravel throws a ValidationException and redirects back to the form page, flashing the errors into the session — exactly like a traditional app.
  3. The Laravel adapter automatically shares those errors with Inertia as page props, so they land in page.props.errors and reactively appear in your component.
  4. Inertia checks page.props.errors to decide which callback fires: errors present means the onError() callback runs instead of onSuccess().

The practical upshot: you never catch validation errors client-side. You display them. Once you internalise that, everything else falls into place.

The useForm Helper: Your Default Tool

Inertia gives you two ways to build forms: the declarative <Form> component and the programmatic useForm helper. Reach for useForm when you want explicit control over submission behaviour — it is the workhorse you will use most often.

Here is a complete, working form for creating a user:

<script setup>
import { useForm } from '@inertiajs/vue3'

const form = useForm({
  name: '',
  email: '',
  avatar: null,
})

function submit() {
  form.post('/users', {
    preserveScroll: true,
    onSuccess: () => form.reset(),
  })
}
</script>

<template>
  <form @submit.prevent="submit">
    <div>
      <label for="name">Name</label>
      <input id="name" v-model="form.name" type="text" />
      <p v-if="form.errors.name" class="error">{{ form.errors.name }}</p>
    </div>

    <div>
      <label for="email">Email</label>
      <input id="email" v-model="form.email" type="email" />
      <p v-if="form.errors.email" class="error">{{ form.errors.email }}</p>
    </div>

    <button type="submit" :disabled="form.processing">
      {{ form.processing ? 'Saving…' : 'Create user' }}
    </button>
  </form>
</template>

That single useForm() call gives you a reactive form object with everything tracked for free:

  • form.name, form.email — the field values, bound directly to your inputs.
  • form.errors — server-side validation errors, keyed by field.
  • form.processing — true while the request is in flight. Bind it to your submit button and double submissions disappear.
  • form.progress — upload progress (0–100) when the form includes files.
  • form.isDirty — true if the user changed anything since the initial values.
  • form.wasSuccessful / form.recentlySuccessful — success flags; recentlySuccessful stays true for two seconds after a successful submission (customisable via form.recentlySuccessfulDuration).
  • form.hasErrors — a quick boolean to check whether any errors exist.

To submit, you get get, post, put, patch, and delete methods. Each accepts the usual visit options — preserveScroll, preserveState, and the onBefore / onSuccess / onError / onFinish callbacks — so you can hook into the submission lifecycle exactly like a manual visit.

Wiring Up Laravel Validation

On the Laravel side, nothing special is required — and that is the point. Your existing validation works as-is. A FormRequest is the cleanest approach:

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'name'   => ['required', 'string', 'max:255'],
            'email'  => ['required', 'email', 'unique:users,email'],
            'avatar' => ['nullable', 'image', 'max:2048'],
        ];
    }
}
<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreUserRequest;
use App\Models\User;

class UserController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        User::create($request->validated());

        return redirect()->route('users.index')
            ->with('success', 'User created successfully.');
    }
}

When validation fails, Laravel throws a ValidationException, redirects back, and Inertia populates form.errors automatically. Your controller always ends in a redirect — you never inspect the response client-side like an XHR call.

Two details worth knowing:

  • Only the first error per field is returned by default. If you want every message (useful when a field fails several rules at once), set $withAllErrors = true in your HandleInertiaRequests middleware. Each field then arrives as an array of strings instead of a single string.
  • You never repopulate old input. When validation fails, Inertia automatically preserves component state for post, put, patch, and delete requests, so every field keeps exactly what the user typed. Your only job is displaying the errors.

Displaying Errors: Three Patterns

You have three ways to work with the errors object, depending on how much control you want.

1. Per-field messages (the default)

Render form.errors.fieldName next to each input, as in the example above. Simple, and what most forms need.

2. Clearing and setting errors manually

form.clearErrors() wipes errors — pair it with form.reset() on cancel buttons so stale messages do not linger. If you do your own client-side checks (or a Zod schema), push your own messages with form.setErrors(); note that unlike a real submission, manually-set errors do not change the page props. To reset values and clear errors in one call, use form.resetAndClearErrors().

3. Reading errors from page props

Errors are also available as page.props.errors (or $page.props.errors in Options API). This matters when you submit with router.post() manually instead of the form helper — there is no form.errors in that case, so you read them from props yourself.

File Uploads and the _method Gotcha

Uploading files in Inertia is almost suspiciously easy: put a File in your form data and Inertia automatically converts the request to FormData. Track the upload with form.progress:

<input type="file" @input="form.avatar = $event.target.files[0]" />
<progress v-if="form.progress" :value="form.progress.percentage" max="100" />

Now the gotcha that bites everyone exactly once: you cannot upload files with PUT, PATCH, or DELETE in Laravel. PHP does not parse multipart/form-data bodies for those methods, so $request->file('avatar') comes back null and you spend an hour questioning your sanity.

The fix is form method spoofing — send the request as POST and include a _method field. Laravel honours it and routes the request as the intended method:

const form = useForm({
  _method: 'put',
  name: user.name,
  avatar: null,
})

function update() {
  // POST under the hood, treated as PUT by Laravel
  form.post(`/users/${user.id}`)
}

Remember to register the corresponding route as Route::put() — Laravel’s router sees the spoofed method, so the route definition stays RESTful.

Error Bags: Two Forms, One Page

Put two forms on one page — say, “update profile” and “change password” — and a shared field name will leak errors into both forms, because both read page.props.errors.

Error bags scope errors to a named bag. On the server, validate into a named bag:

$request->validateWithBag('updateProfile', [
    'name'  => ['required', 'string', 'max:255'],
    'email' => ['required', 'email'],
]);

The errors then arrive under page.props.errors.updateProfile. With the form helper, you can name the bag on submission instead:

form.post('/profile', { errorBag: 'updateProfile' })

Errors are automatically scoped to the form object making the request, so each form only ever shows its own mistakes.

Small UX Wins: transform, isDirty, and Success States

A few helper methods turn a functional form into a polished one:

  • form.transform() modifies the data right before submission — inject an extra field or reshape a value without touching your inputs.
  • form.defaults() updates the baseline values (after loading fresh data, say), so subsequent reset() calls restore to the new defaults and isDirty tracks changes from there.
  • form.recentlySuccessful drives a “Saved” flash message that dismisses itself after two seconds.
  • form.isDirty gates your submit button (:disabled="form.processing || !form.isDirty") and warns on unsaved navigation via a router.on('before') listener.
  • form.dontRemember('password') excludes sensitive fields from the form data Inertia stores in history state.
  • form.cancel() aborts an in-flight submission — handy for large uploads behind a cancel button.

And one checkbox footnote from the docs: give checkboxes an explicit value attribute such as value="1". Without one, a checked box submits as the string "on", which some Laravel validation rules do not recognise as a proper boolean.

Where Precognition Fits

Everything above validates after submission. If you want validate-as-you-type without duplicating your rules in JavaScript, Inertia supports Laravel Precognition: enable it with withPrecognition() on the form helper (or use the built-in support in the <Form> component), then call validate('email') on blur or input. The invalid() and validating helpers drive your UI state, requests are debounced (1.5 seconds by default), and files are excluded from validation requests unless you opt in with validateFiles().

Precognition needs its server-side package configured in Laravel. It is a genuine upgrade for long forms, but for most CRUD forms, solid server-side validation with good error display is all you need.

The 60-Second Checklist

  • Use useForm for programmatic control, <Form> for simple declarative forms.
  • Keep Laravel validation exactly as you would in a Blade app — FormRequest preferred.
  • Display form.errors.field; Inertia never sends you a 422 to catch.
  • Uploading files on update routes? POST + _method: 'put'. Always.
  • Two forms on one page → error bags (validateWithBag / errorBag option).
  • preserveScroll: true on submissions that stay on the same page.
  • form.resetAndClearErrors() on cancel; dontRemember() on passwords.
  • Want every error message per field? Set $withAllErrors = true in the Inertia middleware.

Master this small surface — one helper, one validation flow, three gotchas — and Inertia.js forms stop being a source of mystery bugs and become what they were meant to be: the fastest way to ship forms in a Laravel + Vue 3 app.

Further Reading & References

Leave a Comment