If you've ever built a form in a Vue SPA that talks to a Laravel backend, you know how it goes:
const form = reactive({
name: '',
email: '',
avatar: null,
})
const errors = ref({})
const loading = ref(false)
async function submit() {
loading.value = true
errors.value = {}
try {
await axios.post('/api/users', form)
} catch (e) {
errors.value = e.response.data.errors
} finally {
loading.value = false
}
}
And that's just the basics. Add file uploads, progress tracking, form resetting, dirty state — and suddenly you're maintaining a small library just to handle a form.
Inertia's useForm() replaces all of that. No axios, no manual error mapping, no loading flags — it's all built in, and it talks to your Laravel backend directly.
In this article, we'll build a real user profile form from scratch — with text fields, server-side validation, a file upload with a progress bar, and all the edge cases you'll actually hit in production.
Part 1 — Quick Setup
If you're starting fresh, scaffold a new Laravel project with Inertia and Vue 3 already wired:
laravel new my-app --using=laravel/vue-starter-kit
cd my-app
npm install
That's it — Laravel, Inertia, and Vue 3 are all configured out of the box. Run both servers:
php artisan serve # Laravel on :8000
npm run dev # Vite on :5173
Head to http://localhost:8000 — you should see the default auth scaffolding already working. Inertia is running.
💡 Already have a project? If you're adding Inertia to an existing Laravel app, check the official installation guide — it takes about 10 minutes. Come back once you have a Vue page rendering and we'll take it from there.
We'll build everything around a User Profile feature — update your name, email, and avatar. Simple enough to follow, real enough to actually learn from.
Let's create the route and controller first:
php artisan make:controller ProfileController
// routes/web.php
Route::middleware('auth')->group(function () {
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::post('/profile', [ProfileController::class, 'update'])->name('profile.update');
});
// app/Http/Controllers/ProfileController.php
public function edit(): Response
{
return Inertia::render('Profile/Edit', [
'user' => auth()->user()->only('name', 'email', 'avatar')
]);
}
Notice we're returning Inertia::render() instead of view() — that's the only difference from a regular Laravel controller. Everything else stays the same.
Create the Vue component:
mkdir resources/js/Pages/Profile
touch resources/js/Pages/Profile/Edit.vue
<script setup>
const props = defineProps({
user: Object
})
</script>
<template>
<div>
<h1>Edit Profile</h1>
<pre>{{ user }}</pre>
</div>
</template>
Visit /profile — you should see your user data on screen. That's Inertia working — Laravel passed the data, Vue received it as props. No API endpoint, no fetch call.
Now let's actually build something with it.
Part 2 — Understanding useForm() — What It Actually Does
Before we write the actual form, let's spend two minutes on the mental model — it'll make everything that follows click immediately.
When you use axios manually, you're responsible for four things:
- Holding the form data
- Tracking the loading state
- Catching and mapping errors
- Resetting the form after success
You write that yourself, every single time. useForm() owns all four — you just hand it your initial data and it gives you everything back:
import { useForm } from '@inertiajs/vue3'
const form = useForm({
name: 'John',
email: 'john@example.com',
avatar: null,
})
// That one line gives you:
form.name, form.email // actual field values
form.errors.name // server-side validation error for that field
form.processing // true while the request is in flight
form.progress // upload progress (0–100), only for file uploads
form.isDirty // true if the user changed anything
form.wasSuccessful // true after a successful submission
And these methods you'll use constantly:
form.post(route('profile.update')) // submits the form
form.reset() // resets all fields to initial values
form.clearErrors() // clears validation errors manually
That's the whole API. No reducers, no stores, no custom composables — just useForm() and you're done.
💡 How does it talk to Laravel? Under the hood,
useForm()uses Inertia's router to make the request — not axios, not fetch. This means Laravel sees it as a normal form submission, yourFormRequestvalidation works as-is, and errors come back automatically mapped to the right fields. Zero extra wiring needed.
Here's the full picture of what happens on a form submit:
If User clicks Submit:
→ form.processing = true
→ Inertia sends POST to Laravel
→ Laravel runs FormRequest validation
If validation fails:
→ Laravel redirects back with errors
→ Inertia catches them automatically
→ form.errors.* populated per field
→ form.processing = false
If validation passes:
→ Controller runs, does the work
→ Laravel redirects to next page (or back)
→ form.wasSuccessful = true
→ form.processing = false
The key insight: you never handle errors manually. Laravel sends them, Inertia maps them, useForm() exposes them — all automatically. Your Vue component just reads form.errors.name and displays it.
That's the shift. Now let's build it.
Part 3 — Your First Form: Create & Validate
Let's build the profile form step by step — Vue component first, then the Laravel side.
The Vue Component
<!-- resources/js/Pages/Profile/Edit.vue -->
<script setup>
import { useForm } from '@inertiajs/vue3'
const props = defineProps({
user: Object
})
const form = useForm({
name: props.user.name,
email: props.user.email,
})
function submit() {
form.post(route('profile.update'))
}
</script>
<template>
<form @submit.prevent="submit">
<!-- Name field -->
<div>
<label>Name</label>
<input v-model="form.name" type="text" />
<span v-if="form.errors.name">{{ form.errors.name }}</span>
</div>
<!-- Email field -->
<div>
<label>Email</label>
<input v-model="form.email" type="email" />
<span v-if="form.errors.email">{{ form.errors.email }}</span>
</div>
<!-- Submit button -->
<button type="submit" :disabled="form.processing">
{{ form.processing ? 'Saving...' : 'Save Changes' }}
</button>
</form>
</template>
Three things to notice here:
-
v-model="form.name"— binds the input directly touseForm()state, no extraref()needed -
form.errors.name— server-side validation errors show up here automatically, no mapping code -
:disabled="form.processing"— button disables itself while the request is in flight, preventing double submissions for free
The Laravel Side
Create the FormRequest:
php artisan make:request UpdateProfileRequest
// app/Http/Requests/UpdateProfileRequest.php
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email,' . auth()->id()],
];
}
Update the controller:
// app/Http/Controllers/ProfileController.php
public function update(UpdateProfileRequest $request): RedirectResponse
{
auth()->user()->update($request->validated());
return back()->with('success', 'Profile updated successfully.');
}
That's it — no $request->validate(), no manual error returning, no JSON responses. Laravel handles validation, Inertia handles the error transport, useForm() handles the display. The whole chain just works.
See It In Action
Try submitting the form with an empty name — you'll see the error appear under the field instantly, without a page reload. Fix it and submit again — the error clears automatically.
That's the magic. Same server-side validation you've always written in Laravel, surfacing in Vue with zero extra code.
💡 Flash messages — Notice we returned
back()->with('success', '...'). To display that in Vue, you need to pass flash messages through Inertia's shared data viaHandleInertiaRequestsmiddleware:// app/Http/Middleware/HandleInertiaRequests.php public function share(Request $request): array { return [ ...parent::share($request), 'flash' => [ 'success' => session('success'), 'error' => session('error'), ], ]; }Then in any Vue component:
usePage().props.flash.success
Part 4 — Editing & Resetting Forms
Part 3 covered creating. Now let's talk about editing — which sounds simple but has a few gotchas that catch most people off guard.
Pre-filling the Form With Existing Data
We already did this partially — we passed user as a prop from the controller and initialized useForm() with it:
const form = useForm({
name: props.user.name,
email: props.user.email,
})
This is the correct way to pre-fill a form in Inertia. Don't fetch the data from an API endpoint inside onMounted() — you already have it as a prop. Laravel sent it, Inertia delivered it, Vue received it. Use it directly.
Tracking Dirty State — Warn Before Navigating Away
form.isDirty is true the moment the user changes anything from the original values. This is useful for one very common UX pattern — warning the user before they navigate away with unsaved changes.
Inertia gives you a hook for exactly this:
import { useForm } from '@inertiajs/vue3'
import { router } from '@inertiajs/vue3'
const form = useForm({
name: props.user.name,
email: props.user.email,
})
router.on('before', (event) => {
if (form.isDirty) {
if (!confirm('You have unsaved changes. Are you sure you want to leave?')) {
event.preventDefault()
}
}
})
Clean, no extra libraries, works with Inertia's own router — so it catches navigation triggered by <Link> components and router.visit() calls, not just browser back buttons.
Resetting the Form
Two scenarios — resetting after a successful save, and letting the user cancel their edits manually.
After a successful save, use the onSuccess callback:
function submit() {
form.post(route('profile.update'), {
onSuccess: () => form.reset()
})
}
For a cancel button, reset to the original prop values:
function cancel() {
form.reset()
form.clearErrors()
}
form.reset() resets all fields back to the values you passed into useForm() when you initialized it — not to empty strings, but to the original data. So if the user loaded the form with name: 'John', hitting cancel brings it back to 'John', not ''.
Always pair form.reset() and form.clearErrors() together on cancel.
Putting It All Together
<script setup>
import { useForm, router } from '@inertiajs/vue3'
const props = defineProps({
user: Object
})
const form = useForm({
name: props.user.name,
email: props.user.email,
})
router.on('before', (event) => {
if (form.isDirty) {
if (!confirm('You have unsaved changes. Are you sure you want to leave?')) {
event.preventDefault()
}
}
})
function submit() {
form.post(route('profile.update'), {
onSuccess: () => form.reset()
})
}
function cancel() {
form.reset()
form.clearErrors()
}
</script>
<template>
<form @submit.prevent="submit">
<div>
<label>Name</label>
<input v-model="form.name" type="text" />
<span v-if="form.errors.name">{{ form.errors.name }}</span>
</div>
<div>
<label>Email</label>
<input v-model="form.email" type="email" />
<span v-if="form.errors.email">{{ form.errors.email }}</span>
</div>
<div>
<!-- Save button - disabled when nothing changed or request in flight -->
<button
type="submit"
:disabled="form.processing || !form.isDirty"
>
{{ form.processing ? 'Saving...' : 'Save Changes' }}
</button>
<!-- Cancel button - only visible when something changed -->
<button
v-if="form.isDirty"
type="button"
@click="cancel"
>
Cancel
</button>
</div>
</form>
</template>
Two small details worth pointing out:
-
:disabled="form.processing || !form.isDirty"— the save button is disabled if nothing has changed. No point submitting an unchanged form. -
v-if="form.isDirty"on the cancel button — it only appears when there's actually something to cancel. Cleaner UX, zero extra state.
💡 Partial updates with
form.patch()— If you're updating a resource and don't want to send the entire form payload, useform.patch()instead ofform.post()— it sends aPATCHrequest, semantically correct for partial updates. Wire it to aRoute::patch()on the Laravel side and everything works the same way.
Part 5 — File Uploads With Progress
This is where most tutorials drop the ball — they show you how to attach a file to a form but skip the progress tracking, the validation errors, and what happens when something goes wrong. We'll cover all of it.
Add the Avatar Field to the Form
Add avatar to your existing useForm():
const form = useForm({
name: props.user.name,
email: props.user.email,
avatar: null, // add this
})
Handle the File Input
File inputs can't use v-model — bind them manually with @change instead:
<div>
<label>Avatar</label>
<input
type="file"
accept="image/*"
@change="form.avatar = $event.target.files[0]"
/>
<span v-if="form.errors.avatar">{{ form.errors.avatar }}</span>
</div>
Tell Inertia to Send as Multipart
When your form includes a file, add forceFormData: true to the submit call — this tells Inertia to send the request as multipart/form-data instead of JSON:
function submit() {
form.post(route('profile.update'), {
forceFormData: true,
onSuccess: () => form.reset()
})
}
Show a Progress Bar
form.progress is automatically populated during file uploads — it gives you a percentage value from 0 to 100. Add this right below your file input:
<div v-if="form.progress">
<progress :value="form.progress.percentage" max="100">
{{ form.progress.percentage }}%
</progress>
<span>{{ form.progress.percentage }}%</span>
</div>
It shows up automatically when the upload starts and disappears when it's done. No manual event listeners, no axios upload progress hacks.
Validate on the Laravel Side
Update your UpdateProfileRequest:
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'unique:users,email,' . auth()->id()],
'avatar' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
];
}
Part 6 — Conclusion
Let's look at where we started — a Vue form manually juggling axios calls, error mapping, loading flags, and upload progress events. All boilerplate, all written from scratch, every single time.
Here's what that same form looks like now:
const form = useForm({
name: props.user.name,
email: props.user.email,
avatar: null,
})
function submit() {
form.post(route('profile.update'), {
forceFormData: true,
onSuccess: () => form.reset()
})
}
That's it. Server-side validation errors surface automatically. The submit button disables itself. Upload progress tracks itself. Dirty state tracks itself. Laravel stays Laravel — FormRequests, controllers, redirects, all unchanged.
To recap what we covered:
- Why manual axios form handling creates unnecessary boilerplate
- What
useForm()gives you out of the box and how it talks to Laravel - Building a real form with server-side validation and automatic error display
- Pre-filling edit forms the right way, dirty state tracking, reset and cancel
- File uploads with a real progress bar and proper server-side validation
- All the edge cases — old file cleanup, server limits, input reset after success
useForm() isn't just a convenience — it's the right mental model for forms in an Inertia app. Once it clicks, you'll never want to go back to managing form state manually.
🔗 Stay Connected
Follow me for more Laravel tutorials, dev tips, deployment workflows and solving real-world production headaches.
- Follow me on LinkedIn
- Follow me here on Medium and join my mailing list
- Follow me here on Dev.to for more in-depth content and tutorials!
Found this article useful?
🙏 Show your support by clapping 👏, subscribing 🔔, sharing to social networks

Top comments (0)