If you have a form with 10+ fields, it may make sense to divide them into multiple steps. Let me show you how to do it in Vue.js with Inertia.
In this tutorial, we will build a simple three-step form using Vue.js for the front-end and Inertia to glue with Laravel as a back-end. Link to the repository is included at the end of the tutorial.
We will use Breeze starter kit only for quick Inertia installation.
First, we need to see what the initial Laravel project looks like. Below, you will see the migrations and models.
database/migrations/xxx_create_countries_table.php:
Schema::create('countries', function (Blueprint $table) { $table->id(); $table->string('name'); $table->timestamps();});
database/migrations/xxx_create_cities_table.php:
Schema::create('cities', function (Blueprint $table) { $table->id(); $table->foreignId('country_id'); $table->string('name'); $table->decimal('adult_price'); $table->decimal('children_price'); $table->timestamps();});
app/Models/Country.php:
class Country extends Model{ protected $fillable = [ 'name', ];}
app/Models/City.php:
class City extends Model{ protected $fillable = [ 'country_id', 'name', 'adult_price', 'child_price', ];}
We have a seeder to add some data to have data for select inputs and calculating the price after submitting a form.
database/seeders/DatabaseSeeder.php:
use App\Models\User;use App\Models\City;use App\Models\Country;use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder{ public function run(): void { Country::create(['name' => 'United States']); Country::create(['name' => 'United Kingdom']); Country::create(['name' => 'Germany']); City::create(['country_id' => 1, 'name' => 'New York', 'adult_price' => 100, 'children_price' => 75]); City::create(['country_id' => 1, 'name' => 'Washington', 'adult_price' => 150, 'children_price' => 100]); City::create(['country_id' => 2, 'name' => 'London', 'adult_price' => 200, 'children_price' => 175]); City::create(['country_id' => 2, 'name' => 'Birmingham', 'adult_price' => 250, 'children_price' => 195]); City::create(['country_id' => 3, 'name' => 'Berlin', 'adult_price' => 125, 'children_price' => 85]); City::create(['country_id' => 3, 'name' => 'Stuttgart', 'adult_price' => 190, 'children_price' => 155]); }}
Remember to run
npm run dev
; otherwise, you won't see any changes on the front end.
First, let's create a form. For simplicity, we will add the form to the Dashboard.vue
Vue component. For that, we need to change the route to use a Controller. In the Controller, we will return an Inertia response and pass countries with cities.
php artisan make:controller MultiStepController
app/Http/Controllers/MultiStepController.php:
use Inertia\Inertia;use App\Models\City;use Inertia\Response;use App\Models\Country; class MultiStepController extends Controller{ public function index(): Response { return Inertia::render('Dashboard', [ 'countries' => Country::all()->toArray(), 'cities' => City::all()->groupBy('country_id')->toArray(), ]); }}
routes/web.php:
use App\Http\Controllers\MultiStepController; // ... Route::get('/dashboard', function () { return Inertia::render('Dashboard');})->middleware(['auth', 'verified'])->name('dashboard');Route::get('/dashboard', [MultiStepController::class, 'index'])->middleware(['auth', 'verified'])->name('dashboard'); // ...
In the Vue component, we use a simple v-if
to show text based on the user's current step.
resources/js/Pages/Dashboard.vue:
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';import { Head } from '@inertiajs/vue3'; const currentStep = ref(1) const submit = () => { if (currentStep.value < 3) { currentStep.value = currentStep.value + 1; } else { // Post form }} </script> <template> <Head title="Dashboard" /> <AuthenticatedLayout> <template #header> <h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200" > Dashboard </h2> </template> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" > <div class="p-6 text-gray-900 dark:text-gray-100"> You're logged in! <div class="flex"> <button type="button" v-on:click="currentStep = 1" :class="[currentStep === 1 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none">From</button> <button type="button" v-on:click="currentStep = 2" :class="[currentStep === 2 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none" :disabled="currentStep < 2">To</button> <button type="button" v-on:click="currentStep = 3" :class="[currentStep === 3 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none" :disabled="currentStep < 3">Passengers</button> <div class="flex-grow border-b border-indigo-500"></div> </div> <form @submit.prevent="submit"> <div v-if="currentStep === 1"> Step 1 </div> <div v-if="currentStep === 2"> Step 2 </div> <div v-if="currentStep === 3"> Step 3 </div> <button class="mt-4 inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"> {{ currentStep < 3 ? 'Next' : 'Submit' }} </button> </form> </div> </div> </div> </div> </AuthenticatedLayout></template>
Now, we have a form where we can go to the next step. When the button is clicked, the submit
function is triggered.
Instead of Next
, we show a Submit
text on the last step.
Now, let's add all the selects and inputs to the form. In the Vue component, instead of hard-coded text, let's add form inputs.
resources/js/Pages/Dashboard.vue:
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';import { Head } from '@inertiajs/vue3'; const props = defineProps({ countries: Object, cities: Object,}) const currentStep = ref(1) const submit = () => { if (currentStep.value < 3) { currentStep.value = currentStep.value + 1; } else { // Post form }}</script> <template> <Head title="Dashboard" /> <AuthenticatedLayout> <template #header> <h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200" > Dashboard </h2> </template> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" > <div class="p-6 text-gray-900 dark:text-gray-100"> <div class="flex"> <button type="button" v-on:click="currentStep = 1" :class="[currentStep === 1 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none">From</button> <button type="button" v-on:click="currentStep = 2" :class="[currentStep === 2 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none" :disabled="currentStep < 2">To</button> <button type="button" v-on:click="currentStep = 3" :class="[currentStep === 3 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none" :disabled="currentStep < 3">Passengers</button> <div class="flex-grow border-b border-indigo-500"></div> </div> <form @submit.prevent="submit"> <div v-if="currentStep === 1"> Step 1 <div class="mt-4"> <label for="from-country" class="block text-sm font-medium text-gray-700">From Country</label> <select id="from-country" v-model="form.step1.from_country" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="country in props.countries" :value="country.id" :key="country.id"> {{ country.name }} </option> </select> </div> <div class="mt-4"> <label for="from-city" class="block text-sm font-medium text-gray-700">From City</label> <select id="from-city" v-model="form.step1.from_city" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="city in props.cities[form.step1.from_country]" :value="city.id" :key="city.id"> {{ city.name }} </option> </select> </div> </div> <div v-if="currentStep === 2"> Step 2 <div class="mt-4"> <label for="to-country" class="block text-sm font-medium text-gray-700">To Country</label> <select id="to-country" v-model="form.step2.to_country" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="country in props.countries" :value="country.id" :key="country.id"> {{ country.name }} </option> </select> </div> <div class="mt-4"> <label for="to-city" class="block text-sm font-medium text-gray-700">To City</label> <select id="to-city" v-model="form.step2.to_city" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="city in props.cities[form.step2.to_country]" :value="city.id" :key="city.id"> {{ city.name }} </option> </select> </div> </div> <div v-if="currentStep === 3"> Step 3 <div class="mt-4"> <label for="adults-number" class="block text-sm font-medium text-gray-700">Adults</label> <input v-model="form.step3.adults" id="adults-number" type="number" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required /> </div> <div class="mt-4"> <label for="children-number" class="block text-sm font-medium text-gray-700">Children</label> <input v-model="form.step3.children" id="children-number" type="number" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required /> </div> </div> <button class="mt-4 inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"> {{ currentStep < 3 ? 'Next' : 'Submit' }} </button> </form> </div> </div> </div> </div> </AuthenticatedLayout></template>
Now, the form looks like it should.
Countries and cities in the Vue component are get using props.
In the Vue component, every input has a v-model
, whose value has a prefix of form.
and a step
with a number. This allows for separate step fields, and it's clearer.
Now, it's not enough to define a v-model
. We also need a variable where to set these values. Because this is a form, we will use Inertia form helper.
resources/js/Pages/Dashboard.vue:
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';import { Head } from '@inertiajs/vue3'; const props = defineProps({ countries: Object, cities: Object,}) const currentStep = ref(1)const form = useForm({ step1: { from_country: '', from_city: '', }, step2: { to_country: '', to_city: '', }, step3: { adults: 0, children: 0, },}) const submit = () => { if (currentStep.value < 3) { currentStep.value = currentStep.value + 1; } else { // Post form }}</script> <template>// ...</template>
Now that we have our form and all values are binded, we can submit the form. Here also comes Inertia form helper.
But, before submitting we need a POST route and a method in the Controller.
routes/web.php:
use App\Http\Controllers\MultiStepController; // ... Route::get('/dashboard', [MultiStepController::class, 'index'])->middleware(['auth', 'verified'])->name('dashboard'); Route::middleware('auth')->group(function () { Route::post('multi-step', [MultiStepController::class, 'store'])->name('multi-step.store'); // ...}); require __DIR__.'/auth.php';
app/Http/Controllers/MultiStepController.php:
use Inertia\Inertia;use App\Models\City;use Inertia\Response;use App\Models\Country; class MultiStepController extends Controller{ // ... public function store() { dd('submit'); }}
In the Vue component from the form object, we use a post method. Because Breeze installs the Ziggy package, we can use a route name, or you can provide a link manually.
resources/js/Pages/Dashboard.vue:
<script setup>import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';import { Head } from '@inertiajs/vue3'; const props = defineProps({ countries: Object, cities: Object,}) const currentStep = ref(1)const form = useForm({ step1: { from_country: '', from_city: '', }, step2: { to_country: '', to_city: '', }, step3: { adults: 0, children: 0, },}) const submit = () => { if (currentStep.value < 3) { currentStep.value = currentStep.value + 1; } else { form .post(route('multi-step.store')) }}</script> <template>// ...</template>
After submitting the form, we should see a dump with the text submit
.
Now, let's add validation with one of the rules being city from and to should be different.
php artisan make:request MultiStepFormRequest
app/Http/Requests/MultiStepFormRequest.php:
use Illuminate\Foundation\Http\FormRequest; class MultiStepFormRequest extends FormRequest{ public function rules(): array { return [ 'step1.from_country' => ['required'], 'step1.from_city' => ['required'], 'step2.to_country' => ['required'], 'step2.to_city' => ['required', 'different:step1.from_city'], 'step3.adults' => ['required', 'integer', 'gt:0'], 'step3.children' => ['required', 'integer', 'gte:0'], ]; } public function authorize(): bool { return true; }}
app/Http/Controllers/MultiStepController.php:
use App\Http\Requests\MultiStepFormRequest; class MultiStepController extends Controller{ public function index(): Response { return Inertia::render('Dashboard', [ 'countries' => Country::all()->toArray(), 'cities' => City::all()->groupBy('country_id')->toArray(), ]); } public function store() public function store(MultiStepFormRequest $request) { dd('submit'); }}
On the backend with validation it's all that we need. Laravel with Inertia takes care of everything else. We need to show validation error messages in the front end. The form helper can access these messages using form.errors
.
resources/js/Pages/Dashboard.vue:
<script setup>// ...</script> <template> <Head title="Dashboard" /> <AuthenticatedLayout> <template #header> <h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200" > Dashboard </h2> </template> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800" > <div class="p-6 text-gray-900 dark:text-gray-100"> <div class="flex"> <button type="button" v-on:click="currentStep = 1" :class="[currentStep === 1 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none">From</button> <button type="button" v-on:click="currentStep = 2" :class="[currentStep === 2 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none" :disabled="currentStep < 2">To</button> <button type="button" v-on:click="currentStep = 3" :class="[currentStep === 3 ? 'rounded-sm border border-b-0' : 'border-b']" class="cursor-pointer border-indigo-500 bg-gray-100 p-2 text-gray-700 outline-none focus:outline-none" :disabled="currentStep < 3">Passengers</button> <div class="flex-grow border-b border-indigo-500"></div> </div> <form @submit.prevent="submit"> <div v-if="currentStep === 1"> <div class="mt-4"> <label for="from-country" class="block text-sm font-medium text-gray-700">From Country</label> <select id="from-country" v-model="form.step1.from_country" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="country in props.countries" :value="country.id" :key="country.id"> {{ country.name }} </option> </select> <div class="mt-2 text-sm text-red-600" v-show="form.errors['step1.from_country']"> {{ form.errors['step1.from_country'] }} </div> </div> <div class="mt-4"> <label for="from-city" class="block text-sm font-medium text-gray-700">From City</label> <select id="from-city" v-model="form.step1.from_city" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="city in props.cities[form.step1.from_country]" :value="city.id" :key="city.id"> {{ city.name }} </option> </select> <div class="mt-2 text-sm text-red-600" v-show="form.errors['step1.from_city']"> {{ form.errors['step1.from_city'] }} </div> </div> </div> <div v-if="currentStep === 2"> <div class="mt-4"> <label for="to-country" class="block text-sm font-medium text-gray-700">To Country</label> <select id="to-country" v-model="form.step2.to_country" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="country in props.countries" :value="country.id" :key="country.id"> {{ country.name }} </option> </select> <div class="mt-2 text-sm text-red-600" v-show="form.errors['step2.to_country']"> {{ form.errors['step2.to_country'] }} </div> </div> <div class="mt-4"> <label for="to-city" class="block text-sm font-medium text-gray-700">To City</label> <select id="to-city" v-model="form.step2.to_city" class="mt-2 w-52 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required> <option value="">--</option> <option v-for="city in props.cities[form.step2.to_country]" :value="city.id" :key="city.id"> {{ city.name }} </option> </select> <div class="mt-2 text-sm text-red-600" v-show="form.errors['step2.to_city']"> {{ form.errors['step2.to_city'] }} </div> </div> </div> <div v-if="currentStep === 3"> <div class="mt-4"> <label for="adults-number" class="block text-sm font-medium text-gray-700">Adults</label> <input v-model="form.step3.adults" id="adults-number" type="number" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required /> <div class="mt-2 text-sm text-red-600" v-show="form.errors['step3.adults']"> {{ form.errors['step3.adults'] }} </div> </div> <div class="mt-4"> <label for="children-number" class="block text-sm font-medium text-gray-700">Children</label> <input v-model="form.step3.children" id="children-number" type="number" class="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" required /> <div class="mt-2 text-sm text-red-600" v-show="form.errors['step3.children']"> {{ form.errors['step3.children'] }} </div> </div> </div> <button class="mt-4 inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white transition duration-150 ease-in-out hover:bg-gray-700 focus:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 active:bg-gray-900"> {{ currentStep < 3 ? 'Next' : 'Submit' }} </button> </form> </div> </div> </div> </div> </AuthenticatedLayout></template>
Great. We can see validation messages.
But would it be great if we set an active step on which the first validation message is? Here, again, comes Inertia form helper. When submitting a form, the form helper accepts options. In this case, we need to use onError
callback in the options.
In the callback, we get all the errors. From all the errors, we need to take the first one's key and, using a match, get the step number. Error keys are set in the form request, for example, step1.from_coutry
. Finally, we set the current step value.
resources/js/Pages/Dashboard.vue:
<script setup>// ... const submit = () => { form.clearErrors() if (currentStep.value < 3) { currentStep.value = currentStep.value + 1; } else { form .post(route('multi-step.store')) .post(route('multi-step.store'), { onError: (errors) => { let firstErrorKey = Object.keys(errors)[0]; let stepNumberMatch = firstErrorKey.match(/step(\d+)/); currentStep.value = Number(stepNumberMatch[1]) } }) }}</script> <template>// ...</template>
That's awesome. When validation isn't on the last step, the user can see it immediately.
We can successfully submit the form. All that is left is to calculate the price and redirect to a page where this price will be shown.
In the Controller, we can get all the information needed from the request.
app/Http/Controllers/MultiStepController.php:
use Illuminate\Http\RedirectResponse; class MultiStepController extends Controller{ public function store(MultiStepFormRequest $request) public function store(MultiStepFormRequest $request): RedirectResponse { dd('submit'); $cityPrice = City::find($request->integer('step2.to_city')); $price = $cityPrice->adult_price * $request->integer('step3.adults') + $cityPrice->children_price * $request->integer('step3.children'); return redirect()->route('success')->with('price', $price); }}
We redirected to a success
route, but we don't have it. Let's create it.
routes/web.php:
use App\Http\Controllers\ProfileController;use Illuminate\Foundation\Application;use Illuminate\Support\Facades\Route;use Inertia\Inertia;use App\Http\Controllers\MultiStepController; Route::get('/', function () { return Inertia::render('Welcome', [ 'canLogin' => Route::has('login'), 'canRegister' => Route::has('register'), 'laravelVersion' => Application::VERSION, 'phpVersion' => PHP_VERSION, ]);}); Route::get('/dashboard', [MultiStepController::class, 'index'])->middleware(['auth', 'verified'])->name('dashboard'); Route::middleware('auth')->group(function () { Route::post('multi-step', [MultiStepController::class, 'store'])->name('multi-step.store'); Route::inertia('success', 'Success')->name('success'); Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');}); require __DIR__.'/auth.php';
Next, manually create a Success.vue
Vue component in the resources/js/Pages
directory.
In the Success Vue component, we need to get the price. In this case, we cannot get it from a prop because we add price to the session when redirecting it to the success page. First, we need to use a shared data by adding a key to the HandleInertiaRequests
Middleware.
app/Http/Middleware/HandleInertiaRequests.php:
class HandleInertiaRequests extends Middleware{ // ... public function share(Request $request): array { return [ ...parent::share($request), 'auth' => [ 'user' => $request->user(), ], 'price' => session('price') ?? null, ]; }}
Now, in the Vue component, we can access the price using usePage()
.
resources/js/Pages/Success.vue:
<script setup>import { usePage, Head } from '@inertiajs/vue3';import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';</script> <template> <Head title="Success" /> <AuthenticatedLayout> <template #header> <h2 class="text-xl font-semibold leading-tight text-gray-800" > Success </h2> </template> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg" > <div class="p-6 text-gray-900"> <span class="font-bold">Price:</span> ${{ usePage().props.price }} </div> </div> </div> </div> </AuthenticatedLayout></template>
And that's it. We have a working multi-step form using Vue.js and Inertia.
Here's the link to the GitHub repository with the complete code.