Content Index
- 1. Laravel Inertia and why it facilitates form handling?
- How Vue and Laravel integrate into the same flow
- Real advantages when creating dynamic forms
- 2. Preparing the Laravel controller to handle forms
- create() method and data submission to the component
- store() method to save records with Inertia
- 3. Creating a basic form in Inertia with Vue
- Submitting data using useForm
- State management: processing, errors, wasSuccessful
- Edit Records based on Click Event
- 1. Modify the list to enable edit mode
- 2. The update method on the client (Vue + Inertia)
- What happens here?
- 3. Laravel Controller
- 4. Define the update route
- Router Methods
- Configuration Options
- Browser History
- Component State
- Preserve Scroll Position
- Custom Headers
- FormData for Files
- The Form Component (Inertia v3)
- Slot Props
- Form Context
- Event Callbacks
- Changes in Inertia v3
- useHttp Hook (Inertia v3)
- Optimistic Updates in Inertia.js 3
- 1. The Traditional Approach (Pessimistic)
- Implementation with router.visit
- Use in Forms
- The Inertia
- Import and Structure
- 2. Use of Slots and Destructuring
- Advantages of recentlySuccessful and processing
- Functional Test
Forms are the components primarily used to obtain data from the user, and they are one of the main features we have in Laravel Inertia. We can use them perfectly while maintaining the classic Laravel server-side schema, but using Vue with Inertia on the client side.
Working with a form in Laravel Inertia is one of the best ways to combine the robustness of the Laravel backend with the fluidity of Vue on the client.
When I started working with Inertia, something that really surprised me was how natural it feels to handle forms: they feel like traditional Laravel forms, but with the advantages of an SPA, without having to build a complete API.
In this guide, I show you exactly how to implement a form for both creating and editing records, including:
- Laravel controllers
- validation
- props
- error handling
Forms are a fundamental tool for collecting user information. When we talk about the update process, we usually start from a listing where the user chooses which record they want to modify.
Upon selecting an item, the application redirects them to the edit form. This form already includes the record's identifier, which typically travels in the URL and allows the backend to know which data should be loaded from the database.
Just like in any HTML form, we can include any type of field: inputs, selects, textareas, uploads, etc.
The key difference with Inertia is that everything flows like an SPA, without full page reloads, maintaining the simplicity of the typical Laravel lifecycle.
Once the user has edited the information in the form, they can submit the form by clicking an "Update" or "Submit" button so that Laravel handles the validations and subsequent saving to the database if the data is correct, or displays the validation errors.
Laravel Inertia is nothing more than a scaffolding or skeleton for Laravel, which adds functionality to a Laravel project to integrate Vue as the client-side technology, with Laravel as the server-side technology.
1. Laravel Inertia and why it facilitates form handling?
Inertia is a bridge that eliminates the typical divorce between backend and frontend. Technically, it's not a framework, but a scaffolding that allows you to use Vue, React, or Svelte as views, while Laravel continues to control routes, controllers, and validation.
How Vue and Laravel integrate into the same flow
In my case, I describe Inertia as "Vue on steroids," because I can continue using my Laravel controllers exactly as they are, but instead of returning a view(), I return an inertia(), which displays a Vue component with included data.
Real advantages when creating dynamic forms
- You can use v-model just like in any Vue app.
- You can submit forms with router.post/put/delete without reloading the page.
- Laravel still handles validation with FormRequest.
- The form state (errors, processing, etc.) is managed by useForm.
Inertia greatly simplifies the form handling process with Vue, since we don't have to implement a Rest API, and the useForm object facilitates the validation process.
2. Preparing the Laravel controller to handle forms
In Laravel, in a controller that works with categories, we will create the methods to present the Vue component, using the inertia() function instead of the classic view() function that allows rendering a Vue component.
create() method and data submission to the component
And the store function, to store the category data in the database; that is, to create the category:
app/Http/Controllers/Dashboard/CategoryController.php
<?php
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Category;
use Illuminate\Http\Request;
class CategoryController extends Controller
{
public function create()
{
return inertia("Dashboard/Category/Create");
}
}store() method to save records with Inertia
In my projects, I usually process it like this:
class CategoryController extends Controller
{
public function store(Request $request)
{
Category::create(
[
'title' => request('title'),
'slug' => request('slug'),
]
);
dd($request->all());
}
}3. Creating a basic form in Inertia with Vue
The form is created with its respective v-models and I process the submit to send the form to the previous method:
resources/js/Pages/Dashboard/Category/Create.vue
<template>
<form @submit.prevent="submit">
<label for="">Title</label>
<input type="text" v-model="form.title" />
<label for="">Slug</label>
<input type="text" v-model="form.slug" />
<button type="submit">Send</button>
</form>
</template>For the script section, we use the useForm() function which allows us to manage the form in a simple way; it's a helper that facilitates error handling and overall form status:
array:10 [
"title" => "Test"
"slug" => "test-slug"
"isDirty" => true
"errors" => []
"hasErrors" => false
"processing" => false
"progress" => null
"wasSuccessful" => false
"recentlySuccessful" => false
"__rememberable" => true
]Submitting data using useForm
Finally, we have the use of the get, post, put, path, or delete functions, via the Inertia object in JavaScript which we use to submit the form:
<script>
import { router } from '@inertiajs/vue3'
import { useForm } from "@inertiajs/inertia-vue3";
export default {
setup() {
const form = useForm({
title: null,
slug: null,
});
function submit() {
router.post(route("category.store"), form);
}
return { form, submit };
},
};
</script>State management: processing, errors, wasSuccessful
The helper automatically provides this:
form.processing
form.errors
form.wasSuccessfulAnd it synchronizes without you having to manually manage any state.
Edit Records based on Click Event
Editing an element directly in the listing —without using modals or additional screens— provides a very fluid user experience and is extremely simple to implement with Vue and Inertia.
In this case, the idea is that when the user clicks on the name of a to-do, that <span> is replaced by an <input> to allow for inline editing. Upon pressing Enter, the update is sent to the server using Inertia.
Below I will explain step-by-step how to implement it.
1. Modify the list to enable edit mode
File: resources/js/Pages/Todo/Index.vue
<li v-for="t in todos" class="border py-3 px-4 mt-2" :key="t">
<span v-show="!t.editMode" @click="t.editMode = true">
{{ t.name }}
</span>
<TextInput
v-show="t.editMode"
v-model="t.name"
@keyup.enter="update(t)"
/>
<button @click="remove" class="float-right">
***
</button>
</li>Explanation
To each to-do, we add an additional property called editMode, which doesn't exist in the model but is added dynamically on the client side.
- editMode === true → The <input> for editing is shown.
- editMode === false → The <span> with the to-do name is shown.
Thanks to Vue's reactivity, changing editMode or the name is automatically reflected in the interface.
Behavior
When the user clicks on the <span>, edit mode is activated.
When they press Enter inside the <input>, the update(t) method is executed.
2. The update method on the client (Vue + Inertia)
update(todo) {
todo.editMode = false;
router.put(route("todo.update", todo.id), {
name: todo.name,
});
},What happens here?
The edit mode is deactivated (editMode = false).
A PUT request is sent to the backend using Inertia.router.put.
The new name of the to-do is sent.
This allows for a fluid experience without full page reloads.
3. Laravel Controller
File: app/Http/Controllers/TodoController.php
public function update(Todo $todo, Request $request)
{
$request->validate([
'name' => 'required|string|max:255'
]);
Todo::where("id", $todo->id)
->where('user_id', auth()->id())
->update([
'name' => $request->name,
]);
return back();
}Important points:
- The name field is validated.
- It is ensured that only the user who owns the to-do can edit it (where('user_id', auth()->id())).
- Only the name is updated.
4. Define the update route
File: routes/web.php
Route::group([
'prefix' => 'todo',
'middleware' => ['auth:sanctum', config('jetstream.auth_session'), 'verified']
], function () {
Route::get('/', [TodoController::class, 'index'])->name('todo.index');
Route::post('store', [TodoController::class, 'store'])->name('todo.store');
Route::put('update/{todo}', [TodoController::class, 'update'])->name('todo.update');
});Router Methods
You can also use the router directly for more controlled requests:
import { router } from '@inertiajs/vue3'
router.get(url, data, options)
router.post(url, data, options)
router.put(url, data, options)
router.patch(url, data, options)
router.delete(url, options)Configuration Options
Browser History
By default, Inertia adds a new entry to the browser history. Use replace to replace the current entry instead of creating a new one:
router.get('/post', data, { replace: true })Component State
By default, visits to the same page force a fresh page component instance, which clears any local state, such as form inputs, scroll positions, and focus states.
In certain situations, it is necessary to preserve the page component state. For example, when submitting a form, you need to retain the form data in case validation errors are returned.
Visits to the same page create a new component instance, which clears local state (form inputs, scroll, focus). To preserve the state:
router.get('/post', data, { preserveState: true })Preserve Scroll Position
When navigating between pages, Inertia mimics the default browser behavior by automatically resetting the scroll position of the document body (meaning that when sending a request, the scroll resets to the beginning) back to the top; to prevent this, use the preserveScroll option:
Inertia automatically resets the scroll to the top when navigating
router.put('/update', data, { preserveScroll: true })Custom Headers
The headers option allows you to add custom headers; for example:
router.post('/post', data, {
headers: {
'Custom-Header': 'value',
},
})FormData for Files
When sending files, you need to tell Inertia to use FormData:
router.post('/upload', {
image: file,
}, {
forceFormData: true,
})The Form Component (Inertia v3)
Inertia v3 introduces the <Form> component which automatically handles form state, validation errors, and processing states.
<template>
<Form
action="/users"
method="post"
#default="{
errors,
hasErrors,
processing,
progress,
wasSuccessful,
recentlySuccessful,
setError,
clearErrors,
resetAndClearErrors,
defaults,
isDirty,
reset,
submit,
}"
>
<input type="text" name="name" />
<div v-if="errors.name">{{ errors.name }}</div>
<button type="submit" :disabled="processing">
{{ processing ? 'Saving...' : 'Save' }}
</button>
<span v-if="recentlySuccessful">Saved successfully!</span>
</Form>
</template>Slot Props
- errors - Object with validation errors (uses dot notation)
- hasErrors - Boolean indicating if errors exist
- processing - Boolean while the request is in progress
- progress - File upload progress
- wasSuccessful - Boolean after a successful response
- recentlySuccessful - Boolean for temporary success (useful for visual feedback)
- isDirty - Boolean indicating unsaved changes
- defaults - Method to update default values
- setError(field, message) - Set error manually
- clearErrors() - Clear all errors
- reset() - Restore to default values
- submit() - Submit form manually
Form Context
To access the form state from child components without passing props:
import { useFormContext } from '@inertiajs/vue3'
const form = useFormContext()
// Available if inside a Form component
<div v-if="form">
<span v-if="form.isDirty">Unsaved changes</span>
<button @click="form.submit()">Submit</button>
</div>Event Callbacks
Callbacks are executed at different stages when making the HTTP request:
router.post('/post', data, {
onBefore: (visit) => {},
onStart: (visit) => {},
onProgress: (progress) => {},
onSuccess: (page) => {},
onError: (errors) => {},
onCancel: () => {},
onFinish: (visit) => {},
})- onBefore, when the request is initiated.
- onProgress, when the request is in progress.
- onSuccess, when a response exists.
- onError, when a problem occurs.
Changes in Inertia v3
In Inertia v3, some events were renamed:
invalid→httpExceptionexception→networkErrorcancel()→cancelAll()
useHttp Hook (Inertia v3)
The useHttp hook provides a modern way to handle HTTP requests independently of navigation:
import { useHttp } from '@inertiajs/vue3'
const http = useHttp({
name: '',
email: '',
})
// Basic requests
http.get('/api/users')
http.post('/api/users', { name: 'John' })
// With options
http.post('/api/users', { name: 'John' }, {
onSuccess: () => console.log('User created'),
})Optimistic Updates in Inertia.js 3
Optimistic updates are an interface design technique that prioritizes perceived speed.
Instead of waiting for the server to confirm that the data was saved, the interface updates instantly assuming the operation will succeed.
1. The Traditional Approach (Pessimistic)
Normally, the flow is as follows: the user clicks, a loading indicator is shown, we wait for the server's response (latency, database, email sending), and finally, we update the interface in the onSuccess method:
resources\js\pages\todo\Index.vue
// *** Normal updates
const status = (todo: any) => {
// Updates BEFORE making changes on the server, but, if there are server problems it does NOT revert
// todo.status = todo.status == '1' ? '0' : '1';
// We use todoStatus(id).url
router.post(todoStatus(todo.id).url, {
status: todo.status == '1' ? '0' : '1',
//status: todo.status,
},
// onFinish always executes, useful for clearing states
{
onError: (errors) => {
add('Validation or server error');
},
onSuccess: () => {
// Revert loaders if you had them
// Updates WHEN APPLYING the changes on the server (Slower)
todo.status = todo.status == '1' ? '0' : '1';
}
},
);
};
If we simulate a delay with a sleep(2), the experience is frustrating: the user clicks and has to wait two seconds to see the change. This doesn't feel natural:
app\Http\Controllers\TodoController.php
class TodoController extends Controller
{
***
function status(Todo $todo)
{
sleep(2);
$randomError = rand(1, 10) <= 7;
if ($randomError) {
// abort(422, 'Random error occurred - optimistic update will rollback');
// return response()->json([
// 'message' => 'Random error occurred - optimistic update will rollback'
// ], 422);
// This sends the error back to Inertia correctly
throw ValidationException::withMessages([
'status' => 'Random error occurred - optimistic update will rollback',
]);
}
Todo::where("id", $todo->id)->where("user_id", auth()->id())->update([
'status' => request('status') == '1'
]);
return redirect(route('todo.index'));
}
} Implementation with router.visit
Inertia.js facilitates this through the onOptimistic block. Let's analyze how it works in a To-Do component where we want to change the "completed" status.
Previously, we changed the status manually, which could leave the interface in an invalid state if the server failed:
resources\js\pages\todo\Index.vue
const status = (todo: any) => {
// Updates BEFORE making changes on the server, but, if there are server problems it does NOT revert
todo.status = todo.status == '1' ? '0' : '1';
router.post(todoStatus(todo.id).url, {
status: todo.status,
},With the new onOptimistic method, Inertia takes care of the management for us:
resources\js\pages\todo\Index.vue
const status = (todo: any) => {
const newStatus = todo.status == '1' ? '0' : '1';
router
.optimistic((props) => ({
todos: props.todos.map((t) =>
t.id === todo.id ? { ...t, status: newStatus } : t
),
}))
*** Same as before, POST, PUT...
};That is to say:
const status = (todo: any) => {
const newStatus = todo.status == '1' ? '0' : '1';
router
.optimistic((props) => ({
todos: props.todos.map((t) =>
t.id === todo.id ? { ...t, status: newStatus } : t
),
}))
.post(todoStatus(todo.id).url, {
status: newStatus,
}, {
// Use onError for validation errors (422)
onError: (errors) => {
add('Validation or logic error', 'error');
},
// onFinish always executes, useful for clearing states
onFinish: () => {
// Revert loaders if you had them
}
});
};Here:
.optimistic((props) => ({
todos: props.todos.map((t) =>
t.id === todo.id ? { ...t, status: newStatus } : t
),
}))What we do is the optimistic operation, that is, thinking that there will NOT be a server error and therefore it applies the change to the UI automatically. However, just like with migrations in Laravel, with that declaration Inertia already knows what to revert if errors occur:
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
***
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};How does the "Magic" work?
- User Click: Whatever is inside onOptimistic is executed immediately. The interface changes instantly (the icon changes status).
- Background Request: The server processes the update while the user already sees the result.
- Error Handling (Rollback): If the server returns an error (for example, a 422 validation error or a 500 error), Inertia automatically performs the rollback. You don't have to manually program the logic to "go back"; the state returns to its original value based on the server data:
//*** Server error abort(422, 'Random error occurred - optimistic update will rollback'); //*** Server error with JSON return response()->json([ 'message' => 'Random error occurred - optimistic update will rollback' ], 422); // This sends the error back to Inertia correctly //*** Validation error throw ValidationException::withMessages([ 'status' => 'Random error occurred - optimistic update will rollback', ]);
Use in Forms
This technique is not limited to the router. You can also use it with the useForm hook. It is very useful, for example, for a "Like" button:
- Action: The user "Likes".
- Optimism: We increment the counter by +1 visually.
- Error: If the network fails, the counter returns to its previous number.
<Form
action="/todos"
method="post"
:optimistic="(props, data) => ({
todos: [...props.todos, { id: Date.now(), name: data.name, done: false }],
})"
>It is very similar to database migrations: just as we have an up method to create and a down method to revert, the optimistic update assumes the up and executes the down (rollback) automatically if something goes wrong.
Using onOptimistic saves us from performing manual checks and makes our application feel incredibly fast. Remember that this technique is ideal for light and frequent operations where the probability of success is high.
The Inertia <Form> Component
Let's now go to the advanced form component that we see in the official documentation and in the same components that the project brings, such as Edit.vue.
Let's implement it so you can see how it simplifies all state management.
Import and Structure
The first thing is to import the component directly from the Inertia package. This component acts as a "wrapper":
<Form :action="store()" class="space-y-6">
***
</Form>
import { Head, useForm, Form } from '@inertiajs/vue3'2. Use of Slots and Destructuring
Unlike the manual method, here we take advantage of the slot by default to directly obtain the variables we need: errors, processing and recentlySuccessful (you can look for references in the official documentation or examples of components in the Inertia app).
Note that it is no longer necessary to declare errors as a Prop, since the component is given to us "as a gift" in the form context. Modern syntax uses the hash (#default) to access these data or v-slot:
<Form :action="store()" class="space-y-6"
v-slot="{ errors, processing, recentlySuccessful }"
>
***
<div class="flex items-center gap-4">
<Button type="submit" :disabled="processing">
Save
</Button>
<Transition enter-active-class="transition ease-in-out" enter-from-class="opacity-0"
leave-active-class="transition ease-in-out" leave-to-class="opacity-0">
<p v-show="recentlySuccessful" class="text-sm text-neutral-600">
Saved.
</p>
</Transition>
</div>
</Form>Advantages of recentlySuccessful and processing
This is the most powerful part of the modern scheme:
- processing: Returns true automatically while the request travels to the server, which gives us allows you to disable the submit button without extra logic.
- recentlySuccessful: It is a boolean that is activated right after a successful response and is automatically hides after a few seconds. It's perfect for displaying that little "Saved" text with a smooth transition.
Functional Test
If we try to send the form with errors, you will see that the request arrives correctly and the messages appear where they should. When creating a category (for example, "Category 2"), the success message will appear and disappear thanks to the animation that you can customize to your liking.
We have gone from a manual form to a fully automated one. We no longer declare error props or manage timers for success messages; Inertia and its components do all the heavy lifting.
The next step is for you to learn how to implement file uploads with Drag and Drop in Laravel Inertia.