Laravel 12 Inertia.js + Vue.js CRUD Tutorial (for Beginners)
In this comprehensive guide, you'll learn how to build a Post CRUD (Create, Read, Update, Delete) application using Laravel 12, Inertia.js, and Vue.js 3. This stack lets you enjoy Laravel’s powerful backend with the reactive user experience of Vue, without building a full-blown SPA or using APIs.
Requirements
Before we start, ensure the following are installed:
-
PHP 8.2+
-
Composer
-
Node.js and npm
-
Laravel CLI
-
MySQL/PostgreSQL
-
A web browser
Step 1: Install Laravel 12
First, create a new Laravel project:
composer create-project laravel/laravel example-app
Open .env
file and update the DB settings:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=example-app-crud DB_USERNAME=root DB_PASSWORD=
Step 2: Install Laravel Breeze with Inertia.js + Vue
1. Install Breeze
composer require laravel/breeze --dev
2. Install Inertia Vue stack
php artisan breeze:install vue
3. Install frontend dependencies
npm install
4. Compile frontend assets
npm run dev
5. Migrate default tables
php artisan migrate
Step 3: Create the Posts Table and Model
1. Generate migration
php artisan make:migration create_posts_table
2. Define the schema
Edit the generated file in database/migrations
:
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->timestamps();
});
3. Run migration
php artisan migrate
4. Create Post model
php artisan make:model Post
5. Update model
// app/Models/Post.php
protected $fillable = ['title', 'body'];
Step 4: Create Post Controller
php artisan make:controller PostController
Then update app/Http/Controllers/PostController.php
:
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Inertia\Inertia;
class PostController extends Controller
{
public function index()
{
return Inertia::render('Post/Index', [
'posts' => Post::all(),
]);
}
public function create()
{
return Inertia::render('Post/Create');
}
public function store(Request $request)
{
Post::create($request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
]));
return redirect()->route('posts.index');
}
public function edit(Post $post)
{
return Inertia::render('Post/Edit', ['post' => $post]);
}
public function update(Request $request, Post $post)
{
$post->update($request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
]));
return redirect()->route('posts.index');
}
public function destroy(Post $post)
{
$post->delete();
return redirect()->back();
}
}
Step 5: Define Routes
Open routes/web.php
and add:
use App\Http\Controllers\PostController;
Route::middleware(['auth', 'verified'])->group(function () {
Route::resource('posts', PostController::class);
});
Step 6: Create Vue Pages for CRUD
Create Folder:
mkdir resources/js/Pages/Post
🔹 Index.vue
- List Posts
<script setup> import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'; import { Head, Link, useForm } from '@inertiajs/vue3'; import { ref } from 'vue' defineProps({ posts: { type: Array, default: () => [], }, }); const form = useForm({}); const showDeleteModal = ref(false) const postIdToDelete = ref(null) const deletePost = (id) => { form.delete(`posts/${id}`); }; function openDeleteModal(id) { postIdToDelete.value = id showDeleteModal.value = true } function cancelDelete() { postIdToDelete.value = null showDeleteModal.value = false } function confirmDelete() { deletePost(postIdToDelete.value) showDeleteModal.value = false } </script> <template> <Head title="Manage Posts" /> <AuthenticatedLayout> <template #header> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Manage Posts</h2> </template> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> <Link href="posts/create"> <button class="bg-blue-500 hover:bg-blue-700 text-white text-sm font-semibold py-1 px-3 rounded my-3 flex items-center gap-1"> ➕ Create </button> </Link> <table class="table-auto w-full"> <thead> <tr class="text-left bg-gray-200"> <th class="border px-4 py-2">ID</th> <th class="border px-4 py-2">Title</th> <th class="border px-4 py-2">Content</th> <th class="border px-4 py-2 text-center" width="180px">Action</th> </tr> </thead> <tbody> <tr v-for="(post, index) in posts" :key="post.id" class="text-left hover:bg-gray-100"> <td class="border px-4 py-2">{{ index + 1 }}</td> <td class="border px-4 py-2">{{ post.title }}</td> <td class="border px-4 py-2">{{ post.body }}</td> <td class="border px-4 py-2 text-center"> <Link :href="`posts/${post.id}/edit`"> <button class="bg-blue-500 hover:bg-blue-700 text-white text-sm font-semibold py-1 px-2 rounded inline-flex items-center gap-1"> ✏️ Edit </button> </Link> <button class="bg-red-500 hover:bg-red-700 text-white text-sm font-semibold py-1 px-2 rounded inline-flex items-center gap-1 ml-2" @click="openDeleteModal(post.id)"> 🗑️ Delete </button> </td> </tr> </tbody> </table> </div> </div> </div> </div> </AuthenticatedLayout> <!-- Delete Confirmation Modal --> <div v-if="showDeleteModal" class="fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50"> <div class="bg-white rounded-lg shadow-md w-full max-w-sm p-6"> <h2 class="text-lg font-semibold text-gray-800 mb-4">Confirm Deletion</h2> <p class="text-gray-600 mb-6">Are you sure you want to delete this post?</p> <div class="flex justify-end space-x-2"> <button @click="cancelDelete" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-semibold py-1 px-4 rounded"> Cancel </button> <button @click="confirmDelete" class="bg-red-500 hover:bg-red-700 text-white font-semibold py-1 px-4 rounded"> Yes, Delete </button> </div> </div> </div> </template>
🔹 Create.vue
- Create Post
<script setup> import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'; import { Head, useForm, Link } from "@inertiajs/vue3"; const form = useForm({ title: "", body: "", }); const submit = () => { form.post("/posts"); }; </script> <template> <Head title="Create Post" /> <AuthenticatedLayout> <template #header> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Post</h2> </template> <div class="py-12"> <div class="max-w-3xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> <!-- Back Button --> <Link href="/posts"> <button class="bg-gray-500 hover:bg-gray-700 text-white text-sm font-semibold py-1 px-3 rounded inline-flex items-center gap-1 mb-4"> 🔙 Back </button> </Link> <!-- Post Form --> <form @submit.prevent="submit"> <div class="mb-4"> <label for="title" class="block text-gray-700 text-sm font-bold mb-2">Title:</label> <input id="title" v-model="form.title" type="text" placeholder="Enter Title" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" /> <div v-if="form.errors.title" class="text-red-500 text-sm mt-1">{{ form.errors.title }}</div> </div> <div class="mb-4"> <label for="body" class="block text-gray-700 text-sm font-bold mb-2">Body:</label> <textarea id="body" v-model="form.body" placeholder="Enter Body" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" ></textarea> <div v-if="form.errors.body" class="text-red-500 text-sm mt-1">{{ form.errors.body }}</div> </div> <!-- Submit Button --> <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white text-sm font-semibold py-2 px-4 rounded inline-flex items-center gap-1" :disabled="form.processing" > ✅ Submit </button> </form> </div> </div> </div> </div> </AuthenticatedLayout> </template>
🔹 Edit.vue
- Edit Post
<script setup> import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue'; import { Head, useForm, Link } from "@inertiajs/vue3"; // Props const props = defineProps({ post: { type: Object, required: true, }, }); // Form with initial data from props const form = useForm({ title: props.post.title, body: props.post.body, }); // Submit update request const submit = () => { form.put(`/posts/${props.post.id}`); }; </script> <template> <Head title="Edit Post" /> <AuthenticatedLayout> <template #header> <h2 class="font-semibold text-xl text-gray-800 leading-tight">Edit Post</h2> </template> <div class="py-12"> <div class="max-w-3xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> <!-- Back Button --> <Link href="/posts"> <button class="bg-gray-500 hover:bg-gray-700 text-white text-sm font-semibold py-1 px-3 rounded inline-flex items-center gap-1 mb-4"> 🔙 Back </button> </Link> <!-- Edit Form --> <form @submit.prevent="submit"> <div class="mb-4"> <label for="title" class="block text-gray-700 text-sm font-bold mb-2">Title:</label> <input id="title" type="text" v-model="form.title" placeholder="Enter Title" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" /> <div v-if="form.errors.title" class="text-red-500 text-sm mt-1">{{ form.errors.title }}</div> </div> <div class="mb-4"> <label for="body" class="block text-gray-700 text-sm font-bold mb-2">Body:</label> <textarea id="body" v-model="form.body" placeholder="Enter Body" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" ></textarea> <div v-if="form.errors.body" class="text-red-500 text-sm mt-1">{{ form.errors.body }}</div> </div> <!-- Submit Button --> <button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white text-sm font-semibold py-2 px-4 rounded inline-flex items-center gap-1" :disabled="form.processing" > 💾 Update </button> </form> </div> </div> </div> </div> </AuthenticatedLayout> </template>
Step 7: Add Posts to Navigation Menu
Open resources/js/Layouts/AuthenticatedLayout.vue
, and inside your navigation area:
<NavLink :href="route('posts.index')" :active="route().current('posts.index')"> Posts </NavLink>
Step 8: Run the App
Start Laravel and the Vite dev server:
php artisan serve npm run dev
Visit: http://localhost:8000/posts
Laravel Backend (Server-Side)
app/ │ ├── Http/ │ ├── Controllers/ │ │ ├── PostController.php │ │ └── Auth/ │ └── Middleware/ │ ├── Models/ │ └── Post.php │ ├── Policies/ │ └── Providers/
Routes
routes/
├── web.php # Inertia (Vue) frontend routes
├── api.php # Optional (if using separate API)
Vue + Inertia Frontend (Client-Side)
resources/
└── js/
├── App.vue # Main Vue root component
├── bootstrap.js # Inertia and Vue setup
├── Pages/ # Inertia pages (one file = one route)
│ ├── Dashboard.vue
│ └── Posts/
│ ├── Index.vue # List of posts
│ ├── Create.vue # Create post form
│ └── Edit.vue # Edit post form
│
├── Components/ # Reusable components
│ ├── NavLink.vue
│ ├── Pagination.vue
│ └── FormInput.vue
│
└── Layouts/
├── AuthenticatedLayout.vue
└── GuestLayout.vue
Views
resources/views/
└── app.blade.php # Blade template for Inertia entrypoint
Public
public/
└── images/ # Optional for uploaded images
Database
database/
├── factories/
│ └── PostFactory.php
├── migrations/
│ └── 2024_01_01_create_posts_table.php
└── seeders/
└── PostSeeder.php
Summary of Key Files
File | Purpose |
---|---|
PostController.php | Handles CRUD logic |
resources/js/Pages/Posts/*.vue | Vue pages for the Post entity |
AuthenticatedLayout.vue | Shared layout for authenticated users |
NavLink.vue | Navigation component with active route |
web.php | Laravel routes pointing to Inertia pages |
app.blade.php | Blade view mounting Vue SPA |
Optional: Add Styling
Breeze uses Tailwind CSS by default. You can customize your components using Tailwind classes, or enhance them with tools like:
Conclusion
You now have a fully working CRUD application using:
-
✅ Laravel 12
-
✅ Inertia.js
-
✅ Vue 3
-
✅ Tailwind CSS
This modern stack is great for building reactive, SEO-friendly apps with server-side simplicity and client-side interactivity.