Copied!
Laravel
React
InertiaJs

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

laravel-12-inertia-react-crud-todo
Shahroz Javed
Jan 15, 2026 . 120 views

Laravel 12 continues to dominate the PHP ecosystem by providing developers with an unmatched developer experience. When paired with Inertia.js and React, 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 React 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 React 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 React 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 React Interface

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

import { useForm, router } from "@inertiajs/react";
import { useState } from "react";

export default function Todo({ todos }) {
    const [editing, setEditing] = useState(null);
    const [showModal, setShowModal] = useState(false);

    const { data, setData, post, put, reset } = useForm({
        title: "",
        description: "",
        priority: "medium",
        status: "pending",
    });

    const submit = (e) => {
        e.preventDefault();

        if (editing) {
            put(`/todos/${editing.id}`, {
                onSuccess: closeModal,
            });
        } else {
            post("/todos", {
                onSuccess: closeModal,
            });
        }
    };

    const openCreate = () => {
        reset();
        setEditing(null);
        setShowModal(true);
    };

    const openEdit = (todo) => {
        setEditing(todo);
        setData(todo);
        setShowModal(true);
    };

    const closeModal = () => {
        setShowModal(false);
        setEditing(null);
        reset();
    };

    return (
        <div className="max-w-6xl mx-auto p-6">
            {/* Header */}
            <div className="flex justify-between items-center mb-6">
                <h1 className="text-2xl font-semibold">Todo List</h1>
                <button
                    onClick={openCreate}
                    className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
                >
                    Add Todo
                </button>
            </div>

            {/* Table */}
            <div className="overflow-x-auto bg-white shadow rounded">
                <table className="min-w-full text-sm">
                    <thead className="bg-gray-100 text-left">
                        <tr>
                            <th className="px-4 py-3">Title</th>
                            <th className="px-4 py-3">Priority</th>
                            <th className="px-4 py-3">Status</th>
                            <th className="px-4 py-3 text-right">Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        {todos.length === 0 && (
                            <tr>
                                <td
                                    colSpan="4"
                                    className="px-4 py-6 text-center text-gray-500"
                                >
                                    No todos found
                                </td>
                            </tr>
                        )}

                        {todos.map((todo) => (
                            <tr
                                key={todo.id}
                                className="border-t hover:bg-gray-50"
                            >
                                <td className="px-4 py-3 font-medium">
                                    {todo.title}
                                </td>

                                <td className="px-4 py-3">
                                    <span
                                        className={`px-2 py-1 rounded text-xs font-medium
                                            ${
                                                todo.priority === "high"
                                                    ? "bg-red-100 text-red-700"
                                                    : todo.priority === "medium"
                                                    ? "bg-yellow-100 text-yellow-700"
                                                    : "bg-green-100 text-green-700"
                                            }`}
                                    >
                                        {todo.priority}
                                    </span>
                                </td>

                                <td className="px-4 py-3">
                                    <span
                                        role="button"
                                        tabIndex={0}
                                        title="Click to change status"
                                        onClick={() =>
                                            router.patch(
                                                `/todos/${todo.id}/status`
                                            )
                                        }
                                        onKeyDown={(e) => {
                                            if (
                                                e.key === "Enter" ||
                                                e.key === " "
                                            ) {
                                                router.patch(
                                                    `/todos/${todo.id}/status`
                                                );
                                            }
                                        }}
                                        className={`
                                            inline-flex items-center gap-1
                                            px-3 py-1 rounded-full text-xs font-semibold
                                            cursor-pointer select-none
                                            transition-all duration-200
                                            hover:scale-105 hover:shadow-sm
                                            focus:outline-none focus:ring-2 focus:ring-offset-1
                                            ${
                                                todo.status === "completed"
                                                    ? "bg-green-100 text-green-700 hover:bg-green-200 focus:ring-green-400"
                                                    : "bg-gray-200 text-gray-700 hover:bg-gray-300 focus:ring-gray-400"
                                            }
                                        `}
                                    >
                                        {todo.status === "completed"
                                            ? "Completed"
                                            : "Pending"}
                                    </span>
                                </td>

                                <td className="px-4 py-3 text-right space-x-2">
                                    <button
                                        onClick={() => openEdit(todo)}
                                        className="text-indigo-600 hover:underline"
                                    >
                                        Edit
                                    </button>

                                    <button
                                        onClick={() =>
                                            router.delete(`/todos/${todo.id}`)
                                        }
                                        className="text-red-600 hover:underline"
                                    >
                                        Delete
                                    </button>
                                </td>
                            </tr>
                        ))}
                    </tbody>
                </table>
            </div>

            {/* Modal */}
            {showModal && (
                <div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
                    <div className="bg-white w-full max-w-lg rounded shadow-lg">
                        <div className="px-6 py-4 border-b flex justify-between items-center">
                            <h2 className="text-lg font-semibold">
                                {editing ? "Edit Todo" : "Add Todo"}
                            </h2>
                            <button
                                onClick={closeModal}
                                className="text-gray-400 hover:text-gray-600"
                            >
                                ✕
                            </button>
                        </div>

                        <form onSubmit={submit} className="px-6 py-4 space-y-4">
                            <div>
                                <label className="block text-sm mb-1">
                                    Title
                                </label>
                                <input
                                    type="text"
                                    className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-indigo-200"
                                    value={data.title}
                                    onChange={(e) =>
                                        setData("title", e.target.value)
                                    }
                                    required
                                />
                            </div>

                            <div>
                                <label className="block text-sm mb-1">
                                    Description
                                </label>
                                <textarea
                                    className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-indigo-200"
                                    rows="3"
                                    value={data.description}
                                    onChange={(e) =>
                                        setData("description", e.target.value)
                                    }
                                />
                            </div>

                            <div className="grid grid-cols-2 gap-4">
                                <div>
                                    <label className="block text-sm mb-1">
                                        Priority
                                    </label>
                                    <select
                                        className="w-full border rounded px-3 py-2"
                                        value={data.priority}
                                        onChange={(e) =>
                                            setData("priority", e.target.value)
                                        }
                                    >
                                        <option value="low">Low</option>
                                        <option value="medium">Medium</option>
                                        <option value="high">High</option>
                                    </select>
                                </div>

                                {editing && (
                                    <div>
                                        <label className="block text-sm mb-1">
                                            Status
                                        </label>
                                        <select
                                            className="w-full border rounded px-3 py-2"
                                            value={data.status}
                                            onChange={(e) =>
                                                setData(
                                                    "status",
                                                    e.target.value
                                                )
                                            }
                                        >
                                            <option value="pending">
                                                Pending
                                            </option>
                                            <option value="completed">
                                                Completed
                                            </option>
                                        </select>
                                    </div>
                                )}
                            </div>

                            <div className="flex justify-end gap-2 pt-4 border-t">
                                <button
                                    type="button"
                                    onClick={closeModal}
                                    className="px-4 py-2 border rounded hover:bg-gray-100"
                                >
                                    Cancel
                                </button>
                                <button
                                    type="submit"
                                    className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700"
                                >
                                    {editing ? "Update" : "Save"}
                                </button>
                            </div>
                        </form>
                    </div>
                </div>
            )}
        </div>
    );
}

Feature Summary

By following this architecture, your application now supports:

  • Instant Updates: Using Inertia's preserveScroll for a smooth UX.
  • Reactive State: React 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 React 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