---
title: Building a Multi-Step Form with Laravel Volt, Folio, and Neon Postgres
subtitle: Learn how to create a multi-step form with Laravel Volt, Folio, and Neon Postgres
author: bobbyiliev
enableTableOfContents: true
createdAt: '2024-10-19T00:00:00.000Z'
updatedOn: '2024-10-19T00:00:00.000Z'
---
In this guide, we'll walk through the process of building a multi-step form using Laravel [Volt](https://livewire.laravel.com/docs/volt), [Folio](https://laravel.com/docs/11.x/folio), and Neon Postgres.
Laravel Volt provides reactivity for dynamic form interactions, Folio offers file-based routing for a clean project structure, and Neon Postgres serves as our scalable database solution.
Our example app will be a job application form with multiple steps, including personal information, education, and work experience.
## Prerequisites
Before we begin, make sure you have:
- PHP 8.1 or higher installed
- Composer for managing PHP dependencies
- A [Neon](https://console.neon.tech/signup) account for Postgres hosting
- Basic familiarity with Laravel and Postgres
## Setting up the Project
Let's start by creating a new Laravel project and setting up the necessary components.
1. Create a new Laravel project:
```bash
composer create-project laravel/laravel job-application-form
cd job-application-form
```
2. Install Laravel Folio for file-based routing:
```bash
composer require laravel/folio
```
3. Install the Volt Livewire adapter for Laravel, this will also install the Livewire package:
```bash
composer require livewire/volt
```
4. After installing Volt, you can install the Volt service provider:
```bash
php artisan volt:install
```
## Configuring the Database Connection
Update your `.env` file with your Neon Postgres credentials:
```env
DB_CONNECTION=pgsql
DB_HOST=your-neon-hostname.neon.tech
DB_PORT=5432
DB_DATABASE=your_database_name
DB_USERNAME=your_username
DB_PASSWORD=your_password
```
Replace `your-neon-hostname.neon.tech`, `your_database_name`, `your_username`, and `your_password` with your Neon Postgres connection details.
## Database Design
Let's create the database migrations for our job application form. We'll use separate tables for each section and leverage Postgres JSON columns for flexible data storage for additional information.
First, let's create the migration for the applicants table using the following `artisan` command:
```bash
php artisan make:migration create_applicants_table
```
Note that the `create_applicants_table` migration name follows the Laravel convention of `create_{table_name}_table`, where `{table_name}` is the name of the table you're creating. That way, Laravel can automatically determine the table name from the migration name, and also it will be easier to identify the purpose of the migration file by its name for other developers.
This command generates a new migration file in the `database/migrations` directory. Open the newly created file and update its content as follows:
```php
id();
$table->string('first_name');
$table->string('last_name');
$table->string('email')->unique();
$table->jsonb('additional_info')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('applicants');
}
};
```
This migration creates the `applicants` table with fields for `first_name`, `last_name`, and `email`. The `email` field is set as unique to prevent duplicate applications. We've also included a `jsonb` column called `additional_info` for storing any extra data that doesn't fit into the predefined columns. This flexibility is one of the advantages of using Postgres with Laravel.
Next, let's create the migration for the educations table:
```bash
php artisan make:migration create_educations_table
```
Update the newly created migration file with the following content:
```php
id();
$table->foreignId('applicant_id')->constrained()->onDelete('cascade');
$table->string('institution');
$table->string('degree');
$table->date('start_date');
$table->date('end_date')->nullable();
$table->jsonb('additional_info')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('educations');
}
};
```
This migration creates the `educations` table. It includes a foreign key `applicant_id` that references the `id` column in the `applicants` table. The `onDelete('cascade')` ensures that if an applicant is deleted, their education records are also removed. We've included fields for the institution, degree, and start/end dates. Again, we have an `additional_info` jsonb column for flexibility.
Finally, let's create the migration for the work experiences table:
```bash
php artisan make:migration create_work_experiences_table
```
Update this migration file with the following content:
```php
id();
$table->foreignId('applicant_id')->constrained()->onDelete('cascade');
$table->string('company');
$table->string('position');
$table->date('start_date');
$table->date('end_date')->nullable();
$table->text('responsibilities');
$table->jsonb('additional_info')->nullable();
$table->timestamps();
});
}
public function down()
{
Schema::dropIfExists('work_experiences');
}
};
```
This migration creates the `work_experiences` table. Similar to the `educations` table, it has a foreign key relationship with the `applicants` table. It includes fields for the company, position, start/end dates, and responsibilities. The `responsibilities` field is of type `text` to allow for longer descriptions. We've also included an `additional_info` jsonb column here.
Now that we've created all our migrations, we can run them to create the tables in our database:
```bash
php artisan migrate
```
This command will execute all the migrations we've just created, setting up the database schema for our job application form.
One thing to note is that we've used the `jsonb` column type for storing additional information in each table. This allows us to store flexible data structures without needing to define a fixed schema. Postgres' JSONB data type is ideal for this use case.
For your Laravel migrations, you should not use the Neon Postgres Pooler. The Pooler is designed to manage connections for long-running processes, such as web servers, and is not necessary for short-lived processes like migrations.
## Creating Models
Next, let's create models for our `Applicant`, `Education`, and `WorkExperience` tables. Models in Laravel are used to interact with database tables and represent the data in your application in an object-oriented way.
Laravel provides an easy way to generate models using the `artisan` command. To create the `Applicant` model run:
```bash
php artisan make:model Applicant
```
This command creates a new file `app/Models/Applicant.php`. Open this file and update it with the following content:
```php
'array',
];
public function educations()
{
return $this->hasMany(Education::class);
}
public function workExperiences()
{
return $this->hasMany(WorkExperience::class);
}
}
```
Now, create the `Education` model:
```bash
php artisan make:model Education
```
Update the newly created file at `app/Models/Education.php` with the following content:
```php
'date',
'end_date' => 'date',
'additional_info' => 'array',
];
public function applicant()
{
return $this->belongsTo(Applicant::class);
}
}
```
Finally, create the `WorkExperience` model:
```bash
php artisan make:model WorkExperience
```
And update the `app/Models/WorkExperience.php` file with the following content:
```php
'date',
'end_date' => 'date',
'additional_info' => 'array',
];
public function applicant()
{
return $this->belongsTo(Applicant::class);
}
}
```
Let's quickly note the most important parts in these model definitions:
- We've used the `$fillable` property to specify which attributes can be mass-assigned. This is a security feature to prevent unintended mass assignment vulnerabilities.
- We've defined relationships between models. An `Applicant` has many `Education` and `WorkExperience` records, while `Education` and `WorkExperience` belong to an `Applicant`.
- We've used the `$casts` property to automatically cast certain attributes to specific types. For example, we're casting the `additional_info` field to an array, which works well with Postgres' JSONB column type.
- The `start_date` and `end_date` fields are cast to date objects, which allows for easy date manipulation in PHP.
These models will allow us to easily interact with our database tables using Laravel's Eloquent ORM. They provide a convenient way to retrieve, create, update, and delete records, as well as define relationships between different tables.
## Creating a layout for the multi-step form
Before we create the form components, let's set up a layout for our multi-step form. We'll create a main layout file that includes the necessary CSS and JavaScript assets including the Livewire scripts.
Create a new Blade layout file at `resources/views/layouts/app.blade.php`:
```blade
Job Application Form
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
@yield('content')
@livewireScripts
```
In this layout file:
- We've included the necessary meta tags for character encoding, viewport settings and the page title.
- We've used the `@vite` directive to include the CSS and JavaScript assets. This directive is provided by the Laravel Vite package, which integrates Laravel with the Vite build tool for modern frontend development.
- We've included the Livewire styles and scripts. Livewire is a full-stack framework for Laravel that allows you to build dynamic interfaces without writing JavaScript.
To compile the frontend assets, you'll need to run the following commands:
```bash
npm install
npm run build
```
## Implementing File-based Routing with Folio
Laravel Folio was introduced in 2023, and it offers a new approach to routing in Laravel applications.
It simplifies routing by allowing you to create routes simply by adding Blade templates to a specific directory. This file-based routing system makes your project structure cleaner and more intuitive.
It is not a replacement for Laravel's built-in routing system but rather a complementary feature that simplifies routing for certain types of applications.
First, let's set up the directory structure for our multi-step form. Create the following directory structure in your `resources/views/pages` folder:
```shell
resources/
└── views/
└── pages/
├── index.blade.php
└── apply/
├── index.blade.php
├── personal-info.blade.php
├── education.blade.php
├── work-experience.blade.php
└── review.blade.php
└── confirmation.blade.php
```
With Folio, each of these Blade files automatically becomes a route. For example:
- `pages/index.blade.php` will be accessible at the root URL `/`
- `pages/apply/personal-info.blade.php` will be accessible at `/apply/personal-info`
To create a Folio page, you can use the `php artisan folio:page` command. For example, to create a page for the personal information step:
```bash
php artisan folio:page apply/personal-info
```
The above will create a blade file for the in `resources/views/pages/apply/personal-info.blade.php`:
```blade
Personal Information
```
You can list all available Folio routes using the following Artisan command:
```bash
php artisan folio:list
```
You can create similar pages for the education, work experience, and review steps:
```bash
php artisan folio:page apply/education
php artisan folio:page apply/work-experience
php artisan folio:page apply/review
```
We will update these files with the form components later in the guide.
The main thing to remember here is that with Folio, you don't need to manually define routes in a separate routes file. The mere presence of a Blade file in the `pages` directory automatically creates a corresponding route.
## Building the Multi-Step Form with Volt
Volt is a powerful addition to Laravel Livewire that allows you to build reactive components without writing JavaScript. Unlike traditional Livewire components, Volt lets you define your component's state and validation rules directly in the view file, eliminating the need for a separate component class.
Let's create Volt components for each step of our multi-step form.
### Personal Information Form
First, create the personal information form component:
```bash
php artisan make:volt personal-info-form
```
That will create a file at `resources/views/livewire/personal-info-form.blade.php`. Update the file with the following content:
```blade
'',
'last_name' => '',
'email' => '',
]);
rules([
'first_name' => 'required|min:2',
'last_name' => 'required|min:2',
'email' => 'required|email|unique:applicants,email',
]);
$saveAndContinue = function () {
$this->validate();
$applicant = \App\Models\Applicant::create($this->only(['first_name', 'last_name', 'email']));
session(['applicant_id' => $applicant->id]);
return redirect()->route('apply.education');
};
?>
Personal Information
```
Quick explanation of the code above:
- We define the component's state using the `state` function, which initializes the form fields.
- The `rules` function sets up validation rules for each field.
- The `saveAndContinue` function handles form submission. It validates the form, creates a new `Applicant` record, stores the `applicant_id` in the session, and redirects to the next step.
- The form fields are bound to the component's state using `wire:model`.
- Validation errors are displayed using `@error`.
In the same way, you can create components for the education, work experience, and review steps.
### Education Form
Next, create the education form component:
```bash
php artisan make:volt education-form
```
Update `resources/views/livewire/education-form.blade.php`:
```blade
'',
'degree' => '',
'start_date' => '',
'end_date' => '',
]);
rules([
'institution' => 'required|min:2',
'degree' => 'required|min:2',
'start_date' => 'required|date',
'end_date' => 'nullable|date|after:start_date',
]);
$saveAndContinue = function () {
$this->validate();
$applicantId = session('applicant_id');
\App\Models\Education::create(array_merge($this->all(), ['applicant_id' => $applicantId]));
return redirect()->route('apply.work-experience');
};
?>
Education
```
### Work Experience Form
Next, let's create the work experience form component:
```bash
php artisan make:volt work-experience-form
```
Update `resources/views/livewire/work-experience-form.blade.php` similar to the previous components:
```blade
'',
'position' => '',
'start_date' => '',
'end_date' => '',
'responsibilities' => '',
]);
rules([
'company' => 'required|min:2',
'position' => 'required|min:2',
'start_date' => 'required|date',
'end_date' => 'nullable|date|after:start_date',
'responsibilities' => 'required|min:10',
]);
$saveAndContinue = function () {
$this->validate();
$applicantId = session('applicant_id');
\App\Models\WorkExperience::create(array_merge($this->all(), ['applicant_id' => $applicantId]));
return redirect()->route('apply.review');
};
?>
Work Experience
```
### Review Form
Finally, create the review form component:
```bash
php artisan make:volt review-form
```
Update `resources/views/livewire/review-form.blade.php` as we did for the other components:
```blade
null]);
mount(function () {
$applicantId = session('applicant_id');
$this->applicant = Applicant::with(['educations', 'workExperiences'])->findOrFail($applicantId);
});
$submit = function () {
session()->flash('message', 'Your application has been submitted successfully!');
return redirect()->route('apply.confirmation');
};
?>
```
These Volt components handle the state management, validation, and submission logic for each step of the multi-step form. That way Volt simplifies the process of creating interactive components by allowing you to define both the logic and the template in a single file.
To use these components in your Folio pages and make the routes named, you can include them like this. Named routes allow you to easily reference routes by name throughout your application. We also need to extend a layout for each page to ensure a consistent structure.
First, in each file, you will define a named route using the `name` function and extend the layout.
- For the `resources/views/pages/apply/personal-info.blade.php` file:
```blade
@extends('layouts.app')
@section('title', 'Personal Information')
@section('content')
@endsection
```
We need to do the same for the other pages:
- For the `resources/views/pages/apply/education.blade.php` file:
```blade
@extends('layouts.app')
@section('title', 'Education')
@section('content')
@endsection
```
- For the `resources/views/pages/apply/work-experience.blade.php` file:
```blade
@extends('layouts.app')
@section('title', 'Work Experience')
@section('content')
@endsection
```
- And for the `resources/views/pages/apply/review.blade.php` file:
```blade
@extends('layouts.app')
@section('title', 'Review')
@section('content')
@endsection
```
### Confirmation Page
Finally, create a confirmation page for the application submission:
```bash
php artisan folio:page apply/confirmation
```
Update the `resources/views/pages/apply/confirmation.blade.php` file:
```blade
@extends('layouts.app')
@section('title', 'Confirmation')
@section('content')
@endsection
```
This page displays a success message after the application is submitted and provides a link to return to the homepage.
## Testing the Multi-Step Form
To manually verify that everything works as expected, follow these steps:
1. If you haven't already, start the Laravel development server:
```
php artisan serve
```
1. Open your browser and navigate to `http://localhost:8000/apply/personal-info`.
1. Fill out the personal information form and submit it. You should be redirected to the education form.
1. Fill out the education form and submit it. You should be redirected to the work experience form.
1. Fill out the work experience form and submit it. You should be redirected to the review page.
1. On the review page, verify that all the information you entered is displayed correctly.
1. Submit the application and verify that you see a success message.
1. To check if the data was persisted correctly:
- Open a database client (like pgAdmin for Postgres) and connect to your Neon database.
- Check the `applicants`, `educations`, and `work_experiences` tables. You should see your submitted data.
- Verify that the `applicant_id` in the `educations` and `work_experiences` tables matches the `id` in the `applicants` table for your submission.
1. Try refreshing the page or closing and reopening your browser, then navigate back to `http://localhost:8000/apply/review`. You should still see your submitted data, demonstrating that the data persists across sessions.
## Testing
Besides manual testing, you can also write automated tests to make sure your multi-step form works correctly. Laravel provides a testing suite that allows you to write unit, feature, and browser tests.
Create feature tests for your multi-step form to ensure each step works correctly. Here's an example for the personal info step:
```php
set('first_name', 'John')
->set('last_name', 'Doe')
->set('email', 'john@example.com')
->call('saveAndContinue')
->assertRedirect('/apply/education');
$this->assertDatabaseHas('applicants', [
'first_name' => 'John',
'last_name' => 'Doe',
'email' => 'john@example.com',
]);
$this->assertNotNull(session('applicant_id'));
}
}
```
This test checks if:
1. The form can be submitted with valid data.
1. The data is correctly stored in the database.
1. The `applicant_id` is stored in the session.
1. The user is redirected to the next step after submission.
You can create similar tests for the education and work experience steps.
To learn more about testing in Laravel, check out the [Testing Laravel Applications with Neon's Database Branching](/guides/laravel-test-on-branch) guide.
## Conclusion
In this guide, we've built a multi-step form using Laravel Volt, Folio, and Neon Postgres. We've covered form validation, data storage, and routing, demonstrating how these tools can be used together to create a dynamic and interactive form.
To further improve this project, consider adding features like:
- File uploads for resumes
- Email notifications to applicants
- An admin interface to review applications
One thing to keep in mind is always to validate and sanitize user inputs, optimize your database queries, and thoroughly test your application before deploying to production.
## Additional Resources
- [Laravel Documentation](https://laravel.com/docs)
- [Neon Documentation](/docs)
- [Neon Branching GitHub Actions Guide](/docs/guides/branching-github-actions)