This tutorial builds a working CRUD module with Laravel 12, React and Inertia.js — list, create, edit, update and delete — without a REST API. It is the fastest way to see how routing, controllers, validation and React pages fit together in a real application. The code is intentionally small; the patterns scale to larger products.
Key takeaway: The short version — one Laravel resource controller renders Inertia pages, one React page per action,
useFormhandles submissions and Laravel validation returns errors automatically. No API routes, no tokens, no second repository.
What we build
A simple order management module
/orders— paginated list with a search field;/orders/create— create form;/orders/{order}/edit— edit form;- delete with confirmation;
- validation errors displayed next to fields;
- flash messages after success.
1. Create the Laravel application
laravel new inertia-crud --react
cd inertia-crud
composer install
npm install
The React starter kit already includes Inertia, React, Vite and authentication. If you are adding this to an existing app, follow the installation guide first.
2. Create the model and migration
php artisan make:model Order -m
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')->constrained()->cascadeOnDelete();
$table->decimal('total', 10, 2);
$table->string('status')->default('pending');
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['status', 'created_at']);
});
class Order extends Model
{
protected $fillable = [
'customer_id',
'total',
'status',
'notes',
];
protected $casts = [
'total' => 'decimal:2',
];
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}
Run the migration
php artisan migrate
3. Create the Form Requests
php artisan make:request StoreOrderRequest
php artisan make:request UpdateOrderRequest
public function rules(): array
{
return [
'customer_id' => ['required', 'exists:customers,id'],
'total' => ['required', 'numeric', 'min:0'],
'status' => ['required', 'in:pending,paid,shipped,cancelled'],
'notes' => ['nullable', 'string', 'max:2000'],
];
}
Use the same rules in both requests; the update request can reuse the store rules.
4. Create the controller
php artisan make:controller OrderController --resource
class OrderController extends Controller
{
public function index(Request $request)
{
$orders = Order::query()
->with('customer:id,name')
->when($request->search, fn ($query, $search) =>
$query->whereHas('customer', fn ($q) =>
$q->where('name', 'like', "%{$search}%")
)
)
->latest()
->paginate(20)
->withQueryString();
return Inertia::render('Orders/Index', [
'orders' => OrderResource::collection($orders),
'filters' => $request->only('search'),
]);
}
public function create()
{
return Inertia::render('Orders/Create', [
'customers' => Customer::select('id', 'name')->orderBy('name')->get(),
]);
}
public function store(StoreOrderRequest $request)
{
$order = Order::create($request->validated());
return redirect()
->route('orders.index')
->with('success', 'Order created.');
}
public function edit(Order $order)
{
return Inertia::render('Orders/Edit', [
'order' => new OrderResource($order->load('customer:id,name')),
'customers' => Customer::select('id', 'name')->orderBy('name')->get(),
]);
}
public function update(UpdateOrderRequest $request, Order $order)
{
$order->update($request->validated());
return redirect()
->route('orders.index')
->with('success', 'Order updated.');
}
public function destroy(Order $order)
{
$order->delete();
return redirect()
->route('orders.index')
->with('success', 'Order deleted.');
}
}
Create an API Resource so the list sends only the fields the UI needs
php artisan make:resource OrderResource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'total' => $this->total,
'status' => $this->status,
'notes' => $this->notes,
'customer' => $this->whenLoaded('customer', fn () => [
'id' => $this->customer->id,
'name' => $this->customer->name,
]),
'created_at' => $this->created_at->toIso8601String(),
];
}
5. Register the routes
use App\Http\Controllers\OrderController;
Route::middleware('auth')->group(function () {
Route::resource('orders', OrderController::class)
->except(['show']);
});
Route::resource creates the routes and names (orders.index, orders.create, orders.store, orders.edit, orders.update, orders.destroy).
6. Build the list page
resources/js/Pages/Orders/Index.jsx
import { Link, router } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Index({ orders, filters }) {
function search(event) {
event.preventDefault();
router.get('/orders', { search: event.target.search.value }, {
preserveState: true,
replace: true,
});
}
return (
<AuthenticatedLayout>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Orders</h1>
<Link href="/orders/create" className="btn">New order</Link>
</div>
<form onSubmit={search} className="mt-4">
<input
name="search"
defaultValue={filters.search ?? ''}
placeholder="Search by customer"
/>
</form>
<table className="mt-6 w-full">
<thead>
<tr>
<th>ID</th>
<th>Customer</th>
<th>Total</th>
<th>Status</th>
<th />
</tr>
</thead>
<tbody>
{orders.data.map((order) => (
<tr key={order.id}>
<td>#{order.id}</td>
<td>{order.customer?.name}</td>
<td>{order.total}</td>
<td>{order.status}</td>
<td>
<Link href={`/orders/${order.id}/edit`}>Edit</Link>
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4">
{orders.links.map((link) => (
<Link
key={link.label}
href={link.url}
dangerouslySetInnerHTML={{ __html: link.label }}
className={link.active ? 'font-bold' : ''}
/>
))}
</div>
</AuthenticatedLayout>
);
}
7. Build the create and edit pages
resources/js/Pages/Orders/Create.jsx
import { useForm } from '@inertiajs/react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';
export default function Create({ customers }) {
const { data, setData, post, processing, errors, reset } = useForm({
customer_id: '',
total: '',
status: 'pending',
notes: '',
});
function submit(event) {
event.preventDefault();
post('/orders', {
onSuccess: () => reset(),
});
}
return (
<AuthenticatedLayout>
<h1 className="text-2xl font-bold">New order</h1>
<form onSubmit={submit} className="mt-6 space-y-4">
<div>
<select
value={data.customer_id}
onChange={(e) => setData('customer_id', e.target.value)}
>
<option value="">Select a customer</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
{errors.customer_id && <p>{errors.customer_id}</p>}
</div>
<div>
<input
type="number"
step="0.01"
value={data.total}
onChange={(e) => setData('total', e.target.value)}
/>
{errors.total && <p>{errors.total}</p>}
</div>
<div>
<select
value={data.status}
onChange={(e) => setData('status', e.target.value)}
>
<option value="pending">Pending</option>
<option value="paid">Paid</option>
<option value="shipped">Shipped</option>
<option value="cancelled">Cancelled</option>
</select>
{errors.status && <p>{errors.status}</p>}
</div>
<div>
<textarea
value={data.notes}
onChange={(e) => setData('notes', e.target.value)}
/>
{errors.notes && <p>{errors.notes}</p>}
</div>
<button disabled={processing}>Save</button>
</form>
</AuthenticatedLayout>
);
}
resources/js/Pages/Orders/Edit.jsx is the same form with put and initial values
const { data, setData, put, processing, errors } = useForm({
customer_id: order.customer?.id ?? '',
total: order.total,
status: order.status,
notes: order.notes ?? '',
});
function submit(event) {
event.preventDefault();
put(`/orders/${order.id}`);
}
8. Add delete with confirmation
import { router } from '@inertiajs/react';
function destroy(order) {
if (confirm(`Delete order #${order.id}?`)) {
router.delete(`/orders/${order.id}`, {
preserveScroll: true,
});
}
}
9. Show flash messages
Share flash data in HandleInertiaRequests
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
],
Render it in the layout
const { flash } = usePage().props;
{flash.success && <div className="alert">{flash.success}</div>}
10. Add authorization
Create a policy and authorize the actions
php artisan make:policy OrderPolicy --model=Order
public function update(User $user, Order $order): bool
{
return $user->id === $order->user_id || $user->isAdmin();
}
$this->authorize('update', $order);
Hide the edit button in React for a better UX, but keep the server-side check.
11. Test the CRUD
public function test_user_can_create_an_order(): void
{
$user = User::factory()->create();
$customer = Customer::factory()->create();
$this->actingAs($user)
->post('/orders', [
'customer_id' => $customer->id,
'total' => 199.99,
'status' => 'pending',
])
->assertRedirect('/orders');
$this->assertDatabaseHas('orders', [
'customer_id' => $customer->id,
'total' => 199.99,
]);
}
public function test_orders_index_renders_the_page(): void
{
$this->actingAs(User::factory()->create());
$this->get('/orders')
->assertInertia(fn (Assert $page) => $page
->component('Orders/Index')
->has('orders.data')
);
}
12. What to build next
- search and filters with partial reloads;
- sorting and pagination;
- bulk actions;
- file uploads;
- role-based permissions;
- activity log;
- export to CSV;
- tests for every action.
FAQ
Do I need an API for CRUD in Inertia?
No. The controller returns Inertia pages and receives form submissions directly. An API is only needed for a second client.
How do I handle validation errors?
Laravel returns a 422 response with the errors; Inertia maps them to the errors object from useForm.
How do I redirect after create or update?
Return redirect()->route('orders.index')->with('success', '...'). Inertia follows the redirect and renders the target page.
How do I delete a record?
Use router.delete() and a Laravel destroy action. Add a confirmation dialog and preserveScroll.
How do I paginate?
Use paginate() in the controller and render orders.links in React. Inertia preserves the query string with withQueryString().
Can I use a UI library?
Yes. Inertia is agnostic. Use Tailwind, shadcn/ui, MUI or anything else; the data flow stays the same.
How do I test Inertia CRUD?
Use assertInertia for page components and props, and standard Laravel assertions for database changes and redirects.
Sources
- Inertia.js documentation
- Laravel controllers
- Laravel validation
- Laravel authorization
- Laravel testing
Need a custom Laravel + React module? Check our Laravel development services or hire a Laravel developer.
Read more: Inertia.js with Laravel and React, Inertia.js forms and validation, Inertia.js authentication and testing Inertia.js applications.