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.
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.