Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Physical Address
304 North Cardinal St.
Dorchester Center, MA 02124
Looking to learn how to build a complete Inertia CRUD application? In this tutorial, we’ll walk you through the entire process of creating a CRUD (Create, Read, Update, Delete) system using Laravel for the backend, Inertia.js for handling requests, and Vue.js for the frontend. This guide is perfect for developers who want to build single-page applications without the overhead of a traditional API. By the end, you’ll have a fully functional Inertia CRUD app built from scratch.
👉 If you haven’t set up Laravel yet, check out our step-by-step guide on how to install Laravel framework 12.x on Windows.
Configure your Laravel project to connect with MySQL or other databases using the .env
file — the first step for any Laravel CRUD project.
Then create the database manually or with your preferred GUI.
The first step in our Inertia CRUD application is setting up the database table that will store the data we’ll be managing. Using Laravel’s migration system, we’ll define the table schema and structure. This allows us to easily control and version our database changes while preparing for CRUD operations.
Open the Command Prompt (CMD), navigate to your project root directory, and run the following command:
php artisan make:migration create_customers_table
Update the up() function with content below
// Inertia CRUD - laravelcenter.com
public function up(): void
{
Schema::create('customers', function (Blueprint $table) {
$table->id();
$table->string('name', 50);
$table->tinyInteger('gender');
$table->string('email', 100);
$table->string('image')->nullable();
$table->timestamps();
});
}
Run the migration:
php artisan migrate
Before jumping into the installation steps, it’s helpful to understand what Inertia.js is and why it’s useful. Inertia.js is a modern approach to building single-page applications (SPAs) using classic server-side frameworks like Laravel. Instead of building a full API backend and a separate frontend (like with Vue or React), Inertia lets you keep the simplicity of server-side routing and controllers while using JavaScript frameworks for rendering views.
Inertia acts as a bridge between your backend and frontend. You write controllers just like in a traditional Laravel app, and instead of returning Blade views, you return JavaScript components. This gives you the speed and user experience of a SPA without the complexity of managing APIs and client-side routing.
For more details, visit the official Inertia.js website: https://inertiajs.com
First, install the Inertia server-side adapter using the Composer package manager.
composer require inertiajs/inertia-laravel
Then publish the middleware:
php artisan inertia:middleware
After running the command, it will create this file app/Http/Middleware/HandleInertiaRequests.php
Once the middleware has been published, append the HandleInertiaRequests
middleware to the web
middleware group in your application’s bootstrap/app.php
file.
use App\Http\Middleware\HandleInertiaRequests;
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
npm install @inertiajs/vue3
Next, we’ll create a Laravel model to interact with the database table. This model serves as the data layer of our Inertia CRUD system, allowing us to perform queries, validations, and relationships easily. The model works hand-in-hand with the controller to fetch and store data in our Laravel + Inertia setup.
Create a file named Customer.php in the PROJECT_ROOT/app/Models directory. You can either use an artisan command or create the file manually.
<?php
// Inertia CRUD - laravelcenter.com
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
//
}
With the model ready, it’s time to create a controller to handle our Inertia CRUD logic. This includes methods to list, create, edit, update, and delete records. The controller will return Inertia responses, passing data to Vue components for display and interaction — all while maintaining a single-page app experience.
Create a file named CustomerInertiaController.php in the PROJECT_ROOT/app/Http/Controllers directory. You can either use an artisan command or create the file manually.
<?php
// Inertia CRUD - laravelcenter.com
namespace App\Http\Controllers;
use App\Models\Customer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class CustomerInertiaController extends Controller
{
public function index(Request $request)
{
$search = $request->get('search');
$gender = $request->get('gender', 0);
$field = $request->get('field', 'created_at');
$order = $request->get('order', 'desc');
$customers = Customer::when($gender > 0, function ($query) use ($gender) {
$query->where('gender', $gender);
})
->when($search, function ($query) use ($search) {
$query->where('name', 'like', '%' . $search . '%');
})->orderBy($field, $order)->paginate(5)->withQueryString();
// return back to compoment
return inertia('Customer', ['data' => $customers]);
}
public function edit($id)
{
return response()->json(Customer::find($id));
}
public function submit(Request $request)
{
// validation
$request->validate([
'name' => 'required|unique:customers,name,' . $request->id,
'gender' => 'required',
'email' => 'required|email:rfc,dns',
]);
// save to database
if ($request->id > 0) {
$data = Customer::find($request->id);
} else {
$data = new Customer();
}
$data->name = $request->name;
$data->gender = $request->gender;
$data->email = $request->email;
// delete uploaded file
if ($request->is_deleted_image == 1 && $request->id > 0) {
if (Storage::disk('public')->exists($data->image)) {
Storage::disk('public')->delete($data->image);
}
$data->image = '';
}
// upload file
else if ($request->hasFile('image')) {
if ($data->image && Storage::disk('public')->exists($data->image)) {
Storage::disk('public')->delete($data->image);
}
$data->image = Storage::disk('public')->put('customer', $request->image);
}
$data->save();
// redirect
return back();
}
public function delete($id)
{
$data = Customer::find($id);
// delete uploaded file
if ($data->image && Storage::disk('public')->exists($data->image)) {
Storage::disk('public')->delete($data->image);
}
$data->delete();
// redirect
return back();
}
}
As we’re using Bootstrap, Vue.js, Day.js on the frontend, we need to ensure all of these packages are properly installed and configured.
With all these tools combined, we can build a clean, interactive, and fully functional Laravel Inertia.js Vue.js CRUD application with great user experience.
Run the following command to install them via npm:
npm install vue vue-loader @vitejs/plugin-vue bootstrap bootstrap-icons dayjs
Since Laravel uses Vite as its default frontend asset bundler, we’ll also use it to manage our CSS and JavaScript. If your Laravel project doesn’t have Vite installed yet, you can add it by running:
npm install vite
To start using Vue in your Laravel application, you need to configure vite.config.js
as shown below:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [
laravel({
input: ['resources/css/app.css', 'resources/js/app.js'],
refresh: true,
}),
vue({
template: {
transformAssetUrls: {
base: null,
includeAbsolute: false,
},
},
}),
]
});
After installing the necessary packages, you need to import them into your project. Open the resources/js/app.js file and update it as follows:
// Inertia CRUD - laravelcenter.com
import './bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css';
import * as bootstrap from 'bootstrap';
window.bootstrap = bootstrap;
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
createInertiaApp({
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.vue', { eager: true })
return pages[`./Pages/${name}.vue`]
},
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el)
},
progress: {
color: 'yellow',
includeCSS: true,
showSpinner: true,
},
})
Although we’re building a frontend with Vue.js, we still need a root Blade view to initialize Inertia. This view loads our Vue components and links them with Laravel routes. In this section, we’ll configure the Blade file to serve as the entry point for our Inertia CRUD interface.
Create a file named app.blade.php in the PROJECT_ROOT/resources/views directory. You can either use an artisan command or create the file manually.
<!-- Inertia CRUD - laravelcenter.com -->
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ env('APP_NAME') }}</title>
<style>
.required label:after {
content: " *";
color: red;
font-weight: bold;
}
</style>
@vite('resources/js/app.js')
@inertiaHead
</head>
<body style="display: none;">
<nav class="navbar navbar-expand-md navbar-dark fixed-top bg-success">
<div class="container-fluid">
<a class="navbar-brand fw-bold" href="{{ url('laravel-inertia-crud') }}">Laravel Center</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"
aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</nav>
<main style="margin-top: 100px;">
@inertia
</main>
</body>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.body.style.display = "block";
});
</script>
</html>
The heart of our Inertia CRUD interface lies in Vue components. We’ll create dynamic Vue components to handle the user interface, including forms for creating and editing data, and tables to display it. These components will consume the data passed from Laravel controllers and handle interactivity on the frontend.
Create a file named Customer.vue in the PROJECT_ROOT/resources/js/Pages directory. You can either use an artisan command or create the file manually.
<template>
<div>
<!-- Delete Modal -->
<div class="modal fade" ref="deleteModal" tabindex="-1" aria-hidden="true" data-bs-keyboard="false"
data-bs-backdrop="static" data-bs-focus="false" style="z-index: 1055;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger fs-4 py-2 text-white">
<i class="bi bi-x-circle"></i> DELETE
</div>
<div class="modal-body text-center fs-4">
Are you sure want to delete?
</div>
<div class="modal-footer justify-content-center">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> NO
</button>
<Link method="delete" :href="deleteURL" class="btn btn-primary px-3" data-bs-dismiss="modal" preserve-state
replace preserve-scroll as="button">
<i class="bi bi-check-lg"></i> YES
</Link>
</div>
</div>
</div>
</div>
<!-- Success Modal -->
<div class="modal fade" ref="successModal" tabindex="-1" aria-hidden="true" data-bs-keyboard="false"
data-bs-focus="false" style="z-index: 1056;">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-success fs-4 py-2 text-white">
<i class="bi bi-check-circle"></i> SUCCESS
</div>
<div class="modal-body text-center fs-5">
Your data has been saved successfully
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-primary px-3" data-bs-dismiss="modal">
<i class="bi bi-check-lg"></i> OK
</button>
</div>
</div>
</div>
</div>
<!-- Form Modal -->
<div class="modal fade" ref="formModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static"
data-bs-keyboard="false" data-bs-focus="false">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-2 bg-secondary text-light">
<h5 class="modal-title" style="font-weight: bold">{{ form.id > 0 ? "Edit" : "Add New" }}
Customer</h5>
</div>
<div class="modal-body">
<div v-if="isLoading" class="py-2 px-4 text-secondary">
<div class="spinner"></div> Loading
</div>
<div v-else>
<form @submit.prevent="saveData" id="form"></form>
<div class="row">
<div class="col-md-8">
<div class="row">
<div class="col-12 mb-2 required">
<label class="form-label">Name</label>
<input type="text" :class="['form-control', { 'is-invalid': form.errors.name }]"
v-model="form.name" ref="autofocus" :disabled="form.processing" />
<span v-if="form.errors.name" class="invalid-feedback">{{ form.errors.name }}</span>
</div>
</div>
<div class="row">
<div class="col-12 mb-2 required">
<label class="form-label">Gender</label>
<select v-model="form.gender" :disabled="form.processing"
:class="['form-select', { 'is-invalid': form.errors.gender }]">
<option v-for="option in dd_gender" :value="option.id">
{{ option.label }}
</option>
</select>
<span v-if="form.errors.gender" class="invalid-feedback">{{ form.errors.gender }}</span>
</div>
</div>
<div class="row">
<div class="col-12 mb-2 required">
<label class="form-label">Email</label>
<input type="text" :class="['form-control', { 'is-invalid': form.errors.email }]"
v-model="form.email" :disabled="form.processing" />
<span v-if="form.errors.email" class="invalid-feedback"> {{ form.errors.email }} </span>
</div>
</div>
</div>
<div class="col-md-4">
<div class="row">
<div class="col-12 mb-2">
<label class="form-label">Photo</label>
<div class="text-center" style="position: relative;">
<i class="bi bi-x-circle fs-3 m-0 p-0 text-danger"
style="position: absolute; right: 20px;top: -20px; cursor: pointer;"
@click.stop="removeImage"></i>
<img :src="form.image_preview" style="width: 180px; height: 180px;cursor: pointer;"
class="img-thumbnail" @click="upload" />
<span v-if="form.errors.image" class="invalid-feedback"> {{ form.errors.image }} </span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger pt-1" data-bs-dismiss="modal">
<i class="bi bi-x-lg"></i> Cancel
</button>
<button type="submit" class="btn btn-primary px-4" :disabled="form.processing" form="form">
<i v-if="form.processing" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></i>
<i v-else class="bi bi-floppy" style="padding-right: 3px;"></i> Save
</button>
</div>
</div>
</div>
</div>
<div class="container">
<div class="pagetitle">
<a type="button" class="btn btn-primary" style="float: right" @click="openModal">
<i class="bi bi-plus-circle"></i> Add New
</a>
<h2>Customers List</h2>
<hr />
</div>
<form @submit.prevent="searchData">
<div class="row pb-3">
<div class="col-md-10">
<div class="row justify-content-start">
<div class="col-lg-3 col-sm-6">
<label class="form-label mb-0">Name</label>
<input type="text" class="form-control" v-model="filterForm.search" placeholder="Search..." />
</div>
<div class="col-lg-3 col-sm-6">
<label class="form-label mb-0">Gender</label>
<select class="form-select" v-model="filterForm.gender">
<option value="0">ALL</option>
<option v-for="option in dd_gender" :value="option.id">
{{ option.label }}
</option>
</select>
</div>
</div>
</div>
<div class="col-md-2 align-self-end">
<button type="submit" class="btn btn-secondary pt-1" style="float: right">
<i class="bi bi-search"></i> Search
</button>
</div>
</div>
</form>
<table class="table table-striped">
<thead class="table-dark">
<tr>
<th width="60px" class="text-center">No</th>
<th width="150px" class="text-center">Photo</th>
<th @click="sortData('name')" style="cursor: pointer">
Name <i class="text-secondary"
:class="filterForm.field == 'name' ? (filterForm.order == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('gender')" style="cursor: pointer;">
Gender <i class="text-secondary"
:class="filterForm.field == 'gender' ? (filterForm.order == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('email')" style="cursor: pointer; padding-right: 50px;">
Email <i class="text-secondary"
:class="filterForm.field == 'email' ? (filterForm.order == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th @click="sortData('created_at')" style="cursor: pointer">
Created Date <i class="text-secondary"
:class="filterForm.field == 'created_at' ? (filterForm.order == 'desc' ? 'bi bi-sort-alpha-down-alt' : 'bi bi-sort-alpha-down') : 'bi bi-arrow-down-up'"></i>
</th>
<th width="150px" class="text-center">Action</th>
</tr>
</thead>
<tbody>
<tr v-if="page.props.data && page.props.data.data && page.props.data.data.length > 0"
v-for="(d, index) in page.props.data.data" :key="d.id">
<th style="vertical-align: middle;text-align: center">{{ page.props.data.from + index }}</th>
<td class="text-center py-2"><img style="width: 60px;height: 60px;"
:src="d.image ? ('storage/' + d.image) : defaultImage" />
</td>
<td style="vertical-align: middle">{{ d.name }}</td>
<td style="vertical-align: middle">{{ d.gender == 1 ? 'Female' : 'Male' }}</td>
<td style="vertical-align: middle">{{ d.email }}</td>
<td style="vertical-align: middle">{{ dayjs(d.created_at).format("DD/MM/YYYY HH:mm:ss") }} </td>
<td style="vertical-align: middle; text-align: center;">
<i class="bi bi-trash delete-icon mx-2 text-danger fs-5" style="cursor: pointer;"
@click.stop="deleteData(d.id)"></i>
<i class="bi bi-pencil-square edit-icon mx-2 text-success fs-5" style="cursor: pointer;"
@click.stop="editData(d.id)"></i>
</td>
</tr>
<tr v-else>
<td colspan="10" class="shadow-none">
No record found
</td>
</tr>
</tbody>
</table>
<div class="row pt-3 float-end">
<!-- Pagination -->
<nav v-if="page.props.data && page.props.data.links && page.props.data.links.length > 3">
<ul class="pagination">
<li v-for="(link, index) in page.props.data.links" :key="index" class="page-item"
:class="{ 'active': link.active, 'disabled': !link.url }">
<Link class="page-link" :href="link.url ?? '#'" v-html="link.label">
</Link>
</li>
</ul>
</nav>
</div>
</div>
</div>
</template>
<script setup>
import { Modal } from "bootstrap";
import { onMounted, onUnmounted, ref } from 'vue';
import { Link, useForm, usePage } from '@inertiajs/vue3';
import dayjs from "dayjs";
const isLoading = ref(false);
const page = usePage();
const dd_gender = ref([
{ id: 1, label: "Female" },
{ id: 2, label: "Male" }
]);
const defaultImage = "default.png";
const form = useForm({
id: 0,
name: null,
gender: null,
email: null,
image: null,
image_preview: defaultImage,
is_deleted_image: null
});
const queryParams = new URLSearchParams(window.location.search);
const filterForm = useForm(
{
name: queryParams.get('search'),
gender: queryParams.get('gender'),
field: queryParams.get('field'),
order: queryParams.get('order')
}
);
const formModal = ref(null);
const formModalInstance = ref(null);
const deleteModal = ref(null);
const deleteModalInstance = ref(null);
const successModal = ref(null);
const successModalInstance = ref(null);
const image_error = ref(null);
const deleteURL = ref('');
const autofocus = ref(null);
const removeImage = () => {
form.is_deleted_image = 1;
form.image = null;
form.image_preview = defaultImage;
image_error.value = null;
};
const upload = () => {
let acceptFileType = ['image/png', 'image/jpg', 'image/jpeg'];
let input = document.createElement('input');
input.type = 'file';
input.accept = '.png,.jpg,.jpeg';
input.onchange = _ => {
let file = input.files[0];
if (!acceptFileType.includes(file.type.toLocaleLowerCase())) {
image_error.valueOf = 'Accept file type: png, jpg, jpeg';
return;
} else if (file.size > 2097152) {
image_error.value = 'File size must be less than 2mb';
return;
}
form.image_preview = URL.createObjectURL(file);
form.image = file;
};
input.click();
};
onMounted(() => {
if (formModal.value) {
formModalInstance.value = new Modal(formModal.value);
formModal.value.addEventListener("shown.bs.modal", () => {
autofocus.value.focus();
autofocus.value.select();
});
formModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
form.reset();
form.clearErrors();
});
}
if (deleteModal.value) {
deleteModalInstance.value = new Modal(deleteModal.value);
deleteModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
});
}
if (successModal.value) {
successModalInstance.value = new Modal(successModal.value);
successModal.value.addEventListener("hide.bs.modal", () => {
document.activeElement?.blur();
});
}
});
onUnmounted(() => {
if (formModalInstance.value) {
formModalInstance.value.dispose();
}
if (deleteModalInstance.value) {
deleteModalInstance.value.dispose();
}
if (successModalInstance.value) {
successModalInstance.value.dispose();
}
});
// add or create
const openModal = () => {
isLoading.value = false;
form.image_preview = defaultImage;
form.image = null;
form.is_deleted_image = null;
formModalInstance.value.show();
};
// submit form
const saveData = () => {
form.post("/laravel-inertia-crud/submit", {
_method: form.id > 0 ? "put" : "post",
preserveState: true,
replace: true,
preserveScroll: true,
onSuccess: () => {
formModalInstance.value.hide();
successModalInstance.value.show();
},
onError: () => autofocus.value.focus()
});
};
// search
const searchData = () => {
filterForm.get('/laravel-inertia-crud', { preserveState: true, replace: true, preserveScroll: true });
};
// sort
const sortData = (field) => {
if (filterForm.field === field) {
filterForm.order = filterForm.order == 'asc' ? 'desc' : 'asc';
} else {
filterForm.field = field;
filterForm.order = 'asc';
}
searchData();
};
// edit
const editData = async (id) => {
openModal();
isLoading.value = true;
const response = await fetch("/laravel-inertia-crud/edit/" + id);
const data = await response.json();
isLoading.value = false;
form.id = data.id;
form.name = data.name;
form.gender = data.gender;
form.email = data.email;
form.image_preview = (data.image ? 'storage/' + data.image : defaultImage);
};
// delete
const deleteData = (id) => {
deleteURL.value = "/laravel-inertia-crud/delete/" + id;
deleteModalInstance.value.show();
};
</script>
By default, Laravel uses Tailwind CSS for pagination views. If your project is using Bootstrap (especially Bootstrap 4 or 5), the pagination links generated by {{ $items->links() }}
may look unstyled or broken.
To fix this, you need to tell Laravel to use Bootstrap-compatible pagination views. You can do this by updating the AppServiceProvider
.
Open the file:app/Providers/AppServiceProvider.php
Inside the boot()
method, add the following line:
use Illuminate\Pagination\Paginator;
public function boot()
{
Paginator::useBootstrap();
}
This tells Laravel to render pagination links using Bootstrap’s markup instead of Tailwind CSS.
Finally, we’ll define web routes for each CRUD action. These routes connect user requests to the controller methods and return Vue components via Inertia. Proper routing is crucial to ensure smooth navigation and a true single-page experience in our Inertia CRUD app.
In routes/web.php
:
<?php
// Inertia CRUD - laravelcenter.com
use App\Http\Controllers\CustomerInertiaController;
use Illuminate\Support\Facades\Route;
Route::prefix('laravel-inertia-crud')->controller(CustomerInertiaController::class)->group(function () {
Route::get('/', 'index');
Route::get('edit/{id}', 'edit');
Route::match(['post', 'put'], 'submit', 'submit');
Route::delete('delete/{id}', 'delete');
});
💡 Tip: To ensure the project loads smoothly without broken images, you can download this default image and place it inside your Laravel project’s
public/
folder. This helps prevent errors when no uploaded images are available yet.
When you’re handling image uploads in Laravel, the uploaded files are typically stored in the storage/app/public
directory. However, these files need to be publicly accessible from the browser. Laravel provides a convenient command to create a symbolic link between the public/storage
directory and storage/app/public
:
php artisan storage:link
This command will generate a symbolic link so you can access uploaded images using a public URL like /storage/your-image.jpg.
After installing the dependencies, you need to compile the assets using Vite. This step processes the CSS and JS files and makes them available for your application. Run the following command:
npm run dev
This will start Vite in development mode and build your CSS and JavaScript files. If you’re preparing for production, you can use npm run build
instead.
Once everything is set up, you can run the project by visiting the following URL in your browser:
http://laravel12x/laravel-inertia-crud
You should now see the customer list with image upload, search, sort, and pagination features working as expected.
By following this guide, you now have a solid understanding of building a full-featured Laravel VueJS CRUD Application , search, sort, and pagination.