Laravel 12 Inertia & Vue3 CRUD Application Tutorial with Example – Step-by-Step Guide
Build a Laravel 12 + Inertia + Vue 3 CRUD app with this step-by-step guide. Learn to create a crud system with image uploads, validation, and responsive UI. Covers setup, migrations, controllers, Vue components, file handling, and Inertia integration. This beginner Laravel Inertia Vue 3 tutorial will guide you through how to create CRUD in Laravel using Inertia and Vue 3.

Features: full CRUD operations, dynamic modals, real-time validation, flash messages, and efficient file management. Perfect for beginners to modern full-stack development with Laravel and Vue.
Build a modern full-stack CRUD application with Laravel 12, Inertia.js, and Vue 3 in this comprehensive tutorial. Learn to implement movie management with image uploads, validation, and responsive UI.
Prerequisites
- PHP 8.2+
- Composer
- Node.js 18+
- Basic Laravel/Vue knowledge
In this Laravel 12 Inertia CRUD example, we'll walk through the Vue 3 Laravel Inertia setup tutorial step by step
Step 1. Project Setup
Create new Laravel project:
laravel new laravel-inertia-vue-crud-example
cd laravel-inertia-vue-crud-example
Install required dependencies:
composer require inertiajs/inertia-laravel
npm install vue @inertiajs/vue3 @vitejs/plugin-vue
Step 2. Database Configuration
Create migration (already provided):
// database/migrations/xxxx_create_movies_table.php
Schema::create('movies', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('director');
$table->date('release_date');
$table->string('number');
$table->decimal('price');
$table->string('image')->nullable();
$table->text('description')->nullable();
$table->tinyInteger('status')->default(0)->comment('1=>Active, 0=>Inactive');
$table->timestamps();
});
Run migrations:
php artisan migrate
Step 3. Model & Controller Setup
php artisan make:model Movie -c
Movie.php Model file:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Movie extends Model
{
protected $guarded = [];
}
MovieController.php file:
<?php
namespace App\Http\Controllers;
use App\Helper\CommonHelper;
use App\Http\Requests\MovieRequest;
use App\Models\Movie;
use Inertia\Inertia;
class MovieController extends Controller
{
public function index()
{
$movies = Movie::query()->get();
return Inertia::render('Movie/Index', [
'movies' => $movies,
]);
}
public function create()
{
return Inertia::render('Movie/Create');
}
public function save(MovieRequest $request)
{
$inputs = $request->validated();
if ($request->file('image')) {
$inputs['image'] = CommonHelper::uploadFile($request->file('image'), 'movie');
}
Movie::query()->create($inputs);
return redirect()->route('movies')->with('message', 'Movie created successfully');
}
public function edit(Movie $movie)
{
return response()->json($movie);
}
public function update(Movie $movie, MovieRequest $request)
{
$inputs = $request->validated();
if ($request->file('image')) {
$inputs['image'] = CommonHelper::uploadFile($request->file('image'), 'movie', $movie->image);
}
$movie->update($inputs);
return redirect()->route('movies')->with('message', 'Movie updated successfully');
}
public function delete(Movie $movie)
{
CommonHelper::removeOldFile('public/movie/'.$movie->image);
$movie->delete();
return redirect()->route('movies')->with('message', 'Movie deleted successfully');
}
}
Step 4. Routing Configuration
web.php routes:
<?php
use App\Http\Controllers\MovieController;
use Illuminate\Support\Facades\Route;
Route::get('/', [MovieController::class, 'index'])->name('movies');
Route::get('/create', [MovieController::class, 'create'])->name('movie.create');
Route::post('/save', [MovieController::class, 'save'])->name('movie.save');
Route::get('{movie}/edit', [MovieController::class, 'edit'])->name('movie.edit');
Route::put('/{movie}/update', [MovieController::class, 'update'])->name('movie.update');
Route::delete('/{movie}/delete', [MovieController::class, 'delete'])->name('movie.delete');
Step 5. Frontend Implementation
Vue Components Structure
resources/js/Pages/
├── Movie/
│ ├── Index.vue
│ └── Create.vue
Index.vue file:
<script>
import FrontLayout from '@/Layouts/FrontLayout.vue';
import { Link, useForm } from '@inertiajs/vue3';
import Create from '@/Pages/Movie/Create.vue';
import axios from 'axios';
export default {
components: { FrontLayout, Link, Create },
emits: ['modalToggle'],
props: {
movies: Object,
},
data() {
return {
title: 'Movie Page',
modalToggle: false,
movie: {},
form: useForm({}),
}
},
methods: {
async editForm(route) {
const res = await axios.get(route);
this.movie = res.data;
this.modalToggle = true;
},
closeModal() {
this.modalToggle = !this.modalToggle;
this.movie = {};
},
deleteData(e) {
let url = e.target.getAttribute('url');
if (confirm('Are you sure you want to delete?')) {
this.form.delete(url, {
onError :(error) => {
console.log(error);
}
});
}
}
}
}
</script>
<template>
<FrontLayout :title="title">
<div class="table-list">
<div class="head">
<h1>Movie List</h1>
<button @click="modalToggle = true">Create</button>
</div>
<div v-if="$page.props.flash.message" class="alert">
{{ $page.props.flash.message }}
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Director</th>
<th>Release Date</th>
<th>Number</th>
<th>Price</th>
<th>Image</th>
<th>Description</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="(movie, index) in movies" :key="index">
<td>{{ movie.name }}</td>
<td>{{ movie.director }}</td>
<td>{{ movie.release_date }}</td>
<td>{{ movie.number }}</td>
<td>{{ movie.price }}</td>
<td>
<img v-if="movie.image" :src="`storage/movie/${movie.image}`" alt=""
style="max-width: 120px; height: auto;">
</td>
<td>{{ movie.description ? movie.description : 'default' }}</td>
<td>{{ movie.status ? 'Active' : 'Inactive' }}</td>
<td>
<div class="action-buttons">
<button class="edit-button"
@click="editForm(route('movie.edit', movie.id))">Edit</button>
<button :url="route('movie.delete', movie.id)" class="delete-button"
@click="deleteData($event)">Delete</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<Create v-if="modalToggle" @modalToggle="closeModal()" :movie="movie" />
</FrontLayout>
</template>
<style scoped>
.table-list {
font-family: Arial, sans-serif;
text-align: center;
margin: 10px 10px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f4f4f4;
}
tr:hover {
background-color: #f9f9f9;
}
.action-buttons {
display: flex;
gap: 10px;
}
.action-buttons a,
.action-buttons button {
padding: 5px 10px;
border: none;
cursor: pointer;
border-radius: 4px;
}
.table-list .head {
display: flex;
justify-content: space-around;
margin-bottom: 20px;
}
.table-list .head button {
font-size: larger;
}
.edit-button {
background-color: #4CAF50;
color: white;
}
.delete-button {
background-color: #f44336;
color: white;
}
.edit-button:hover {
background-color: #45a049;
}
.delete-button:hover {
background-color: #e53935;
}
</style>
Create.vue file:
<script>
import { useForm } from '@inertiajs/vue3';
export default {
components: { useForm },
props: {
movie: Object,
},
data() {
return {
form: {
name: this.movie.name || '',
director: this.movie.director || '',
release_date: this.movie.release_date || '',
number: this.movie.number || '',
price: this.movie.price || '',
image: this.movie.image || '',
description: this.movie.description || '',
status: this.movie.status || 0,
},
imageUrl: this.movie.image ? `/storage/movie/${this.movie.image}` : '',
tempImageFile: null,
errors: {},
}
},
methods: {
previewImage(event) {
const file = event.target.files[0];
if (file && file.type.startsWith("image/")) {
this.tempImageFile = file;
this.imageUrl = URL.createObjectURL(file);
}
},
submitForm(e) {
let url = Object.keys(this.movie).length == 0 ? route('movie.save') : route('movie.update', this.movie.id);
if (this.tempImageFile) {
this.form.image = this.tempImageFile;
}
if (this.movie.id) {
this.form._method = 'put';
}
this.form = useForm(this.form);
this.form.post(url, {
onSuccess: () => {
this.form.reset();
this.tempImageFile = null;
this.$emit('modalToggle');
},
onError: (error) => {
console.log(error);
this.errors = error;
},
});
},
},
}
</script>
<template>
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-header">
Create the movie
</div>
<div class="modal-body">
<form @submit.prevent="submitForm($event)">
<label for="name">Name</label>
<input type="text" id="name" v-model="form.name" placeholder="Enter your name">
<div v-if="this.errors.name" class="error">{{ this.errors.name }}</div>
<label for="option">Director</label>
<select id="option" v-model="form.director">
<option disabled value="">Select Director</option>
<option value="director 1">Director 1</option>
<option value="director 2">Director 2</option>
<option value="director 3">Director 3</option>
</select>
<div v-if="this.errors.director" class="error">{{ this.errors.director }}</div>
<label for="release_date">Release Date</label>
<input type="date" id="release_date" v-model="form.release_date">
<div v-if="this.errors.release_date" class="error">{{ this.errors.release_date }}</div>
<label for="number">Number</label>
<input type="number" id="number" v-model="form.number" placeholder="Enter number">
<div v-if="this.errors.number" class="error">{{ this.errors.number }}</div>
<label for="price">Price</label>
<input type="number" id="price" v-model="form.price" placeholder="Enter price" step="0.01">
<div v-if="this.errors.price" class="error">{{ this.errors.price }}</div>
<label for="image">Image</label>
<input type="file" @change="previewImage" id="image">
<div v-if="imageUrl" class="preview-box">
<p>Image Preview:</p>
<img :src="imageUrl" alt="Preview" class="preview-image" />
</div>
<div v-if="this.errors.image" class="error">{{ this.errors.image }}</div>
<label for="Description">Description</label>
<textarea id="Description" v-model="form.description" rows="4"
placeholder="Enter your Description"></textarea>
<div v-if="this.errors.description" class="error">{{ this.errors.description }}</div>
<label>Status</label>
<div class="radio-group">
<label><input type="radio" v-model="form.status" value='1'> Active</label>
<label><input type="radio" v-model="form.status" value='0'> Inactive</label>
<div v-if="this.errors.status" class="error">{{ this.errors.status }}</div>
</div>
<div class="modal-footer">
<button type="button" class="back-btn" @click="$emit('modalToggle')">Back</button>
<button type="submit" class="save-btn" :disabled="form.processing">
<span v-if="form.processing">Submiting..</span>
<span v-else>Save</span>
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: flex-start;
z-index: 9999;
overflow-y: auto;
padding: 16px;
}
/* Modal container */
.modal {
background-color: #fff;
border-radius: 8px;
width: 90%;
max-width: 400px;
margin: 16px auto;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Modal heading */
.modal-header {
padding: 16px;
border-bottom: 1px solid #ddd;
font-size: 18px;
font-weight: bold;
text-align: center;
}
/* Modal body */
.modal-body {
padding: 16px;
}
.modal-body form {
display: flex;
flex-direction: column;
gap: 12px;
}
.modal-body form label {
font-size: 14px;
margin-bottom: 4px;
}
.modal-body form input,
.modal-body form textarea,
.modal-body form select {
padding: 8px;
font-size: 14px;
border: 1px solid #ccc;
border-radius: 4px;
}
.modal-body form .radio-group {
display: flex;
gap: 16px;
align-items: center;
}
.modal-body form .radio-group label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
cursor: pointer;
}
.modal-body form .radio-group input[type="radio"] {
accent-color: #4caf50;
width: 16px;
height: 16px;
}
.modal-body form .error {
color: red;
}
/* Modal footer */
.modal-footer {
padding: 16px;
border-top: 1px solid #ddd;
display: flex;
justify-content: space-between;
}
.modal-footer button {
padding: 8px 16px;
font-size: 14px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.modal-footer .save-btn {
background-color: #4caf50;
color: white;
}
.modal-footer .back-btn {
background-color: #f44336;
color: white;
}
</style>
FrontLayout.vue:
<script>
import { Head, Link } from '@inertiajs/vue3';
export default {
components: { Head, Link },
props :{
title : String,
}
}
</script>
<template>
<div>
<Head :title="this.title"/>
<nav class="navbar">
<Link :href="route('movies')" class="active">Home</Link>
<Link :href="route('movies')">About</Link>
<Link :href="route('movies')">Contact</Link>
</nav>
<slot />
</div>
</template>
<style>
*{
margin: 0px;
padding: 0px;
}
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #333;
padding: 10px 20px;
}
.navbar a {
color: white;
text-decoration: none;
margin: 0 15px;
font-size: 18px;
transition: color 0.3s ease;
}
.navbar a:hover {
color: #00bcd4;
}
.navbar a.active {
font-weight: bold;
color: #00bcd4;
}
</style>
Step 6. File Upload Handling
CommonHelper.php methods:
<?php
namespace App\Helper;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\Rules\File;
class CommonHelper
{
public static function uploadFile(UploadedFile $file, $path, $oldFile = ''): string
{
if (! empty($file)) {
// Remove Old file
if (! empty($oldFile)) {
Storage::delete('public/'.$path.'/'.$oldFile);
}
// Upload image
$path = $file->store('public/'.$path);
$parts = explode('/', $path);
return end($parts);
}
return '';
}
public static function removeOldFile($oldFile): void
{
if (! empty($oldFile)) {
Storage::delete($oldFile);
}
}
public static function getImageValidationRule(string $key): array
{
if (request()->hasFile($key)) {
return [File::types(['jpeg', 'jpg', 'png', 'webp'])->max(1 * 1024)];
}
return ['string'];
}
}
7. Validation & Error Handling
MovieRequest.php file:
public function rules(): array
{
$rules = [
'name' => ['required', 'string', 'max:255'],
'director' => ['required', 'string', 'max:255'],
'release_date' => ['required', 'date'],
'number' => ['required', 'numeric', 'max_digits:15'],
'price' => ['required', 'numeric', 'min:0'],
'image' => ['nullable'],
'description' => ['required', 'string'],
'status' => ['required'],
];
if (request()->hasFile('image')) {
$rules['image'] = array_merge(CommonHelper::getImageValidationRule('image'));
}
return $rules;
}
Vue form error display:
<div v-if="errors.name" class="error">{{ errors.name }}</div>
Step 8. Inertia Configuration
HandleInertiaRequests middleware:
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
/**
* The root template that's loaded on the first page visit.
*
* @see https://inertiajs.com/server-side-setup#root-template
*
* @var string
*/
protected $rootView = 'app';
/**
* Determines the current asset version.
*
* @see https://inertiajs.com/asset-versioning
*/
public function version(Request $request): ?string
{
return parent::version($request);
}
/**
* Define the props that are shared by default.
*
* @see https://inertiajs.com/shared-data
*
* @return array<string, mixed>
*/
public function share(Request $request): array
{
return [
...parent::share($request),
'flash' => [
'message' => fn () => $request->session()->get('message'),
],
];
}
}
app.js setup:
import { createApp, h } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { ZiggyVue } from '../../vendor/tightenco/ziggy';
createInertiaApp({
resolve: (name) => resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')),
setup({ el, App, props, plugin }) {
createApp({ render: () => h(App, props) })
.use(plugin)
.use(ZiggyVue)
.mount(el)
},
});
9. Running the Application
Start development server:
npm run dev
php artisan serve
Visit http://localhost:8000 to see the working CRUD application with:
- Movie creation/editing
- Image uploads
- Responsive design
- Form validation
- Status management
Key Features Highlight
- Full CRUD Operations: Create, Read, Update, Delete movies
- Image Management: Upload, preview, and delete movie images
- Validation: Server-side and client-side validation
- Responsive UI: Mobile-friendly table and modal
- State Management: Inertia.js form handling
- Flash Messages: Success/error notifications
Conclusion
By following this guide, you've built a fullstack CRUD with Laravel Inertia Vue, suitable for both beginners and experienced developers.
This tutorial demonstrated how to build a modern full-stack application using Laravel's backend power with Vue's reactive frontend through Inertia.js. The complete code implementation shows:
- How to handle file uploads efficiently
- Proper validation techniques
- Inertia.js page rendering
- Vue component communication
- CRUD operations best practices
Customize this base template for your next project by adding features like:
- Pagination
- Search functionality
- User authentication
- Advanced filtering
- API integrations
Click Here: All Source Code Available in GitHub
Happy Coding! 😊