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.

Laravel 12 Inertia & Vue3 CRUD Application Tutorial with Example – Step-by-Step Guide Image

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

  1. Full CRUD Operations: Create, Read, Update, Delete movies
  2. Image Management: Upload, preview, and delete movie images
  3. Validation: Server-side and client-side validation
  4. Responsive UI: Mobile-friendly table and modal
  5. State Management: Inertia.js form handling
  6. 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! 😊

Do you Like?