Handling forms in Laravel 13 Inertia 3 with Vue 3

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.wasSuccessful

And it synchronizes without you having to manually manage any state.

Edit Records based on Click Event

Video thumbnail

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');
});

Métodos del Router

También puedes usar el router directamente para peticiones más controladas:

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)

Opciones de Configuración

Historial del Navegador

Por defecto, Inertia agrega una nueva entrada al historial del navegador. Usa replace para reemplazar la entrada actual en lugar de crear una nueva:

router.get('/post', data, { replace: true })

Estado del Componente

De forma predeterminada, las visitas a la misma página fuerzan una instancia de componente de página nueva, que borra cualquier estado local, como entradas de formulario, posiciones de desplazamiento y estados de enfoque.

En ciertas situaciones, es necesario preservar el estado del componente de la página. Por ejemplo, al enviar un formulario, debe conservar los datos del formulario en caso de que vuelvan a aparecer errores de validación.

Las visitas a la misma página crean una nueva instancia del componente, lo que borra el estado local (entradas de formulario, scroll, enfoque). Para preservar el estado:

router.get('/post', data, { preserveState: true })

Preservar Posición de Scroll

Al navegar entre páginas, Inertia imita el comportamiento predeterminado del navegador restableciendo automáticamente la posición de desplazamiento del cuerpo del documento (es decir, al enviar una petición, el scroll se restablece al inicio), de vuelta a la parte superior; para evitar esto, se una la opción preserveScroll:

Inertia restablece automáticamente el scroll al inicio al navegar

router.put('/update', data, { preserveScroll: true })

Headers Personalizados

La opción de headers permite agregar headers personalizados; por ejemplo:

router.post('/post', data, {
    headers: {
        'Custom-Header': 'value',
    },
})

FormData para Archivos

Cuando envíes archivos, necesitas indicar a Inertia que use FormData:

router.post('/upload', {
    image: file,
}, {
    forceFormData: true,
})

El Componente Form (Inertia v3)

Inertia v3 introduce el componente <Form> que maneja automáticamente el estado del formulario, errores de validación y estados de procesamiento.

<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 ? 'Guardando...' : 'Guardar' }}
        </button>

        <span v-if="recentlySuccessful">¡Guardado exitosamente!</span>
    </Form>
</template>

Props del Slot

  • errors - Objeto con errores de validación (usa notación de puntos)
  • hasErrors - Booleano indicando si hay errores
  • processing - Booleano mientras la petición está en progreso
  • progress - Progreso de subida de archivos
  • wasSuccessful - Booleano después de una respuesta exitosa
  • recentlySuccessful - Booleano短暂成功 (útil para feedback visual)
  • isDirty - Booleano indicando cambios sin guardar
  • defaults - Método para actualizar valores por defecto
  • setError(field, message) - Establecer error manualmente
  • clearErrors() - Limpiar todos los errores
  • reset() - Restaurar a valores por defecto
  • submit() - Enviar formulario manualmente

Form Context

Para acceder al estado del formulario desde componentes hijos sin pasar props:

import { useFormContext } from '@inertiajs/vue3'

const form = useFormContext()

// Available if inside a Form component
<div v-if="form">
    <span v-if="form.isDirty">Cambios sin guardar</span>
    <button @click="form.submit()">Enviar</button>
</div>

Callbacks de Eventos

Los callbacks se ejecutan en diferentes etapas al realizar la petición HTTP:

router.post('/post', data, {
    onBefore: (visit) => {},
    onStart: (visit) => {},
    onProgress: (progress) => {},
    onSuccess: (page) => {},
    onError: (errors) => {},
    onCancel: () => {},
    onFinish: (visit) => {},
})
  • onBefore, cuando se inicia la petición.
  • onProgress, cuando la petición está en proceso.
  • onSuccess, cuando existe una respuesta.
  • onError, cuando ocurre algún problema.

Cambios en Inertia v3

En Inertia v3, algunos eventos fueron renombrados:

  • invalidhttpException
  • exceptionnetworkError
  • cancel()cancelAll()

Hook useHttp (Inertia v3)

El hook useHttp proporciona una forma moderna de manejar peticiones HTTP independientes de la navegación:

import { useHttp } from '@inertiajs/vue3'

const http = useHttp({
    name: '',
    email: '',
})

// Peticiones básicas
http.get('/api/users')
http.post('/api/users', { name: 'John' })

// Con opciones
http.post('/api/users', { name: 'John' }, {
    onSuccess: () => console.log('Usuario creado'),
})

Optimistic Updates in Inertia.js 3

Video thumbnail

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 next step is for you to learn how to implement file uploads with Drag and Drop in Laravel Inertia.

We will know how to handle forms in Laravel Inertia with Vue, the basic flow to use a component in Vue and send the request to Laravel.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.

Andrés Cruz

ES En español