DEV Community

HoudaifaDevBS
HoudaifaDevBS

Posted on • Originally published at Medium

Laravel + Inertia + Vue 3: Forms, Validation & File Uploads Done Right

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Intro


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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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');
});
Enter fullscreen mode Exit fullscreen mode
// app/Http/Controllers/ProfileController.php
public function edit(): Response
{
    return Inertia::render('Profile/Edit', [
        'user' => auth()->user()->only('name', 'email', 'avatar')
    ]);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<script setup>
const props = defineProps({
    user: Object
})
</script>

<template>
    <div>
        <h1>Edit Profile</h1>
        <pre>{{ user }}</pre>
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Holding the form data
  2. Tracking the loading state
  3. Catching and mapping errors
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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, your FormRequest validation 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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Three things to notice here:

  • v-model="form.name" — binds the input directly to useForm() state, no extra ref() 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
Enter fullscreen mode Exit fullscreen mode
// app/Http/Requests/UpdateProfileRequest.php
public function rules(): array
{
    return [
        'name'  => ['required', 'string', 'max:255'],
        'email' => ['required', 'email', 'unique:users,email,' . auth()->id()],
    ];
}
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

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 via HandleInertiaRequests middleware:

// 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,
})
Enter fullscreen mode Exit fullscreen mode

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()
        }
    }
})
Enter fullscreen mode Exit fullscreen mode

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()
    })
}
Enter fullscreen mode Exit fullscreen mode

For a cancel button, reset to the original prop values:

function cancel() {
    form.reset()
    form.clearErrors()
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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, use form.patch() instead of form.post() — it sends a PATCH request, semantically correct for partial updates. Wire it to a Route::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
})
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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()
    })
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'],
    ];
}
Enter fullscreen mode Exit fullscreen mode

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()
    })
}
Enter fullscreen mode Exit fullscreen mode

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.

Found this article useful?

🙏 Show your support by clapping 👏, subscribing 🔔, sharing to social networks

Top comments (0)