Copied!
Laravel
Vue
InertiaJs

Laravel 12 Inertia Vue CRUD Example – Todo App with Status Toggle

laravel-12-inertia-vue-crud-todo
Shahroz Javed
Jan 15, 2026 . 118 views

Laravel 12 continues to dominate the PHP ecosystem by providing developers with an unmatched developer experience. When paired with Inertia.js and Vue 3, you can build single-page applications (SPAs) without the complexity of building a separate API. In this guide, we will walk through creating a robust Todo application to master CRUD operations.

💡 Prerequisite: Before starting the CRUD implementation, ensure your environment is ready. Check out our guide on Laravel 12 Inertia Vue Setup.

Introduction to Modern CRUD

CRUD (Create, Read, Update, Delete) is the backbone of almost every web application. By using Inertia, we eliminate the need for Axios or Fetch calls for every page transition, allowing Laravel to handle the routing while Vue manages the reactive UI.

Database Schema and Migration

Our Todo application requires a structured database. We will define a table to handle titles, descriptions, priorities, and statuses.

// database/migrations/xxxx_xx_xx_create_todos_table.php
public function up(): void
{
    Schema::create('todos', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('description')->nullable();
        $table->enum('priority', ['low', 'medium', 'high'])->default('medium');
        $table->enum('status', ['pending', 'completed'])->default('pending');
        $table->timestamps();
    });
}

Configuring the Eloquent Model

To interact with our database, we need to make our model attributes "fillable." This prevents mass-assignment vulnerabilities.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Todo extends Model
{
    protected $fillable = [
        'title',
        'description',
        'priority',
        'status',
    ];
}

Defining Application Routes

Laravel 12 makes route definitions clean. We will use a mix of standard RESTful routes and a custom PATCH route for toggling statuses.

use App\Http\Controllers\TodoController;
use Illuminate\Support\Facades\Route;

Route::get('/', [TodoController::class, 'index']);
Route::post('/todos', [TodoController::class, 'store']);
Route::put('/todos/{todo}', [TodoController::class, 'update']);
Route::delete('/todos/{todo}', [TodoController::class, 'destroy']);
Route::patch('/todos/{todo}/status', [TodoController::class, 'toggleStatus']);

The Controller Logic

The TodoController acts as the bridge. It fetches data from the database and returns an Inertia response, which injects the data directly into our Vue component.

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Inertia\Inertia;

class TodoController extends Controller
{
    public function index()
    {
        return Inertia::render('Todo', [
            'todos' => Todo::latest()->get(),
        ]);
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'priority' => 'required|in:low,medium,high',
        ]);

        Todo::create($validated);
        return redirect()->back();
    }

    public function update(Request $request, Todo $todo)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'description' => 'nullable|string',
            'priority' => 'required|in:low,medium,high',
            'status' => 'required|in:pending,completed',
        ]);

        $todo->update($validated);
        return redirect()->back();
    }

    public function destroy(Todo $todo)
    {
        $todo->delete();
        return redirect()->back();
    }

    public function toggleStatus(Todo $todo)
    {
        $todo->update([
            'status' => $todo->status === 'pending' ? 'completed' : 'pending',
        ]);
        return redirect()->back();
    }
}
⚠️ Important: Always use redirect()->back() or redirect()->route() in Inertia controllers to ensure the frontend state refreshes correctly after an action.

Building the Vue 3 Interface

The frontend uses Tailwind CSS for styling and the useForm helper from Inertia to handle data submission and validation errors.

<script>
import { ref } from "vue";
import { useForm, router } from "@inertiajs/vue3";

export default {
    props: {
        todos: { type: Array, required: true },
    },
    setup(props) {
        const editing = ref(null);
        const showModal = ref(false);
        const form = useForm({
            title: "",
            description: "",
            priority: "medium",
            status: "pending",
        });

        const submit = () => {
            if (editing.value) {
                form.put(`/todos/${editing.value.id}`, { onSuccess: closeModal });
            } else {
                form.post("/todos", { onSuccess: closeModal });
            }
        };

        const openCreate = () => {
            form.reset();
            editing.value = null;
            showModal.value = true;
        };

        const openEdit = (todo) => {
            editing.value = todo;
            form.title = todo.title;
            form.description = todo.description;
            form.priority = todo.priority;
            form.status = todo.status;
            showModal.value = true;
        };

        const closeModal = () => {
            showModal.value = false;
            editing.value = null;
            form.reset();
        };

        const toggleStatus = (todoId) => {
            router.patch(`/todos/${todoId}/status`, { preserveScroll: true });
        };

        const destroyTodo = (todoId) => {
            router.delete(`/todos/${todoId}`, { preserveScroll: true });
        };

        return { form, editing, showModal, submit, openCreate, openEdit, closeModal, toggleStatus, destroyTodo };
    },
};
</script>

<template>
    <div class="max-w-6xl mx-auto p-6">
        <div class="flex justify-between items-center mb-6">
            <h1 class="text-2xl font-semibold">Todo List</h1>
            <button @click="openCreate" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">
                Add Todo
            </button>
        </div>

        <div class="overflow-x-auto bg-white shadow rounded">
            <table class="min-w-full text-sm">
                <thead class="bg-gray-100 text-left">
                    <tr>
                        <th class="px-4 py-3">Title</th>
                        <th class="px-4 py-3">Priority</th>
                        <th class="px-4 py-3">Status</th>
                        <th class="px-4 py-3 text-right">Actions</th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-if="!todos.length">
                        <td colspan="4" class="px-4 py-6 text-center text-gray-500">No todos found</td>
                    </tr>
                    <tr v-for="todo in todos" :key="todo.id" class="border-t hover:bg-gray-50">
                        <td class="px-4 py-3 font-medium">{{ todo.title }}</td>
                        <td class="px-4 py-3">
                            <span class="px-2 py-1 rounded text-xs font-medium" :class="{'bg-red-100 text-red-700': todo.priority === 'high', 'bg-yellow-100 text-yellow-700': todo.priority === 'medium', 'bg-green-100 text-green-700': todo.priority === 'low'}">
                                {{ todo.priority }}
                            </span>
                        </td>
                        <td class="px-4 py-3">
                            <span role="button" @click="toggleStatus(todo.id)" class="inline-flex items-center px-3 py-1 rounded-full text-xs font-semibold cursor-pointer transition-all hover:scale-105" :class="todo.status === 'completed' ? 'bg-green-100 text-green-700' : 'bg-gray-200 text-gray-700'">
                                {{ todo.status === "completed" ? "Completed" : "Pending" }}
                            </span>
                        </td>
                        <td class="px-4 py-3 text-right space-x-2">
                            <button @click="openEdit(todo)" class="text-indigo-600 hover:underline">Edit</button>
                            <button @click="destroyTodo(todo.id)" class="text-red-600 hover:underline">Delete</button>
                        </td>
                    </tr>
                </tbody>
            </table>
        </div>

        <!-- Modal Part -->
        <div v-if="showModal" class="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
            <div class="bg-white w-full max-w-lg rounded shadow-lg">
                <form @submit.prevent="submit" class="px-6 py-4 space-y-4">
                    <h2 class="text-lg font-semibold">{{ editing ? "Edit Todo" : "Add Todo" }}</h2>
                    <div>
                        <label class="block text-sm">Title</label>
                        <input v-model="form.title" type="text" class="w-full border rounded px-3 py-2" required />
                    </div>
                    <div>
                        <label class="block text-sm">Description</label>
                        <textarea v-model="form.description" class="w-full border rounded px-3 py-2"></textarea>
                    </div>
                    <div class="flex justify-end gap-2 pt-4">
                        <button type="button" @click="closeModal" class="px-4 py-2 border rounded">Cancel</button>
                        <button type="submit" class="px-4 py-2 bg-indigo-600 text-white rounded">{{ editing ? "Update" : "Save" }}</button>
                    </div>
                </form>
            </div>
        </div>
    </div>
</template>

Feature Summary

By following this architecture, your application now supports:

  • Instant Updates: Using Inertia's preserveScroll for a smooth UX.
  • Reactive State: Vue 3's Composition API handles the modal and form states efficiently.
  • Type-Safe Data: Laravel's validation ensures only clean data reaches your database.

Conclusion

Building a CRUD in Laravel 12 with Inertia and Vue 3 provides a modern, high-performance experience without the overhead of a separate frontend repository. This workflow is highly scalable and perfect for both small projects and large enterprise applications.

📑 On This Page