Menggabungkan Laravel dan Inertia.js adalah pendekatan yang praktis ketika Anda ingin membangun aplikasi web modern tanpa harus memisahkan backend API dan frontend SPA secara penuh. Laravel tetap menangani routing, validasi, otorisasi, dan akses database, sementara Inertia.js memungkinkan kita merender halaman frontend berbasis komponen dengan pengalaman yang terasa seperti single-page application.
Pada tutorial ini, kita akan membuat CRUD produk secara end-to-end. Cakupannya meliputi pembuatan tabel dan model, resource controller, halaman index/create/edit, submit form POST/PUT/DELETE, validasi di server, menampilkan error di komponen, menampilkan flash message sukses, dan redirect setelah data berhasil disimpan. Kita juga akan memakai useForm dari Inertia agar penanganan form menjadi lebih ringkas.
Artikel ini berfokus pada pola implementasi yang umum dipakai. Nama folder frontend bisa berbeda tergantung apakah Anda menggunakan Vue, React, atau Svelte. Contoh di sini memakai Vue karena paling sering dipakai bersama Inertia.js, tetapi konsepnya tetap sama untuk adapter lain.
1. Gambaran Arsitektur dan Alur CRUD
Dalam kombinasi Laravel + Inertia, alurnya berbeda dari API murni. Saat user mengakses halaman produk, request dikirim ke route Laravel. Controller Laravel lalu mengembalikan Inertia response yang berisi nama komponen frontend dan data yang dibutuhkan halaman. Komponen frontend menerima props tersebut dan merender UI.
Ketika form dikirim, frontend tidak memanggil endpoint JSON secara manual seperti pada SPA tradisional. Sebaliknya, kita menggunakan helper dari Inertia, misalnya form.post() atau form.put(). Request tetap diproses oleh Laravel seperti biasa. Jika validasi gagal, Laravel mengembalikan error dan Inertia otomatis memetakkannya ke properti error di form. Jika sukses, controller dapat redirect ke halaman lain sambil membawa flash message.
Keuntungan pendekatan ini:
- Validasi tetap terpusat di server, sehingga aturan bisnis tidak tersebar.
- Tidak perlu membangun API terpisah untuk kebutuhan CRUD internal.
- Pengalaman pengguna lebih mulus dibanding full page reload biasa.
- Kode form lebih sederhana berkat
useForm.
Trade-off-nya, jika Anda benar-benar membutuhkan frontend yang sepenuhnya independen atau akan dipakai banyak klien non-web, maka arsitektur API terpisah bisa lebih tepat.
2. Persiapan Proyek dan Struktur Data Produk
Membuat migration dan model
Misalkan kita ingin menyimpan data produk dengan kolom: nama, deskripsi, harga, dan stok. Buat model beserta migration:
php artisan make:model Product -mIsi file migration kira-kira seperti berikut:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->decimal('price', 12, 2);
$table->unsignedInteger('stock')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('products');
}
};Lalu jalankan migration:
php artisan migrateSetelah itu, isi model Product:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Product extends Model
{
protected $fillable = [
'name',
'description',
'price',
'stock',
];
}Mengapa fillable penting? Karena pada operasi create/update kita akan memakai mass assignment, misalnya Product::create($validated). Tanpa fillable, Laravel akan menolak field tersebut demi keamanan.
Routing resource
Buat controller resource:
php artisan make:controller ProductController --resourceLalu definisikan route di routes/web.php:
use App\Http\Controllers\ProductController;
Route::resource('products', ProductController::class);Dengan satu baris ini, Laravel menyediakan route index, create, store, edit, update, dan destroy.
3. Controller Resource, Validasi Server-Side, dan Flash Message
Berikut contoh implementasi controller yang cukup lengkap untuk kebutuhan CRUD produk:
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use Illuminate\Http\Request;
use Inertia\Inertia;
class ProductController extends Controller
{
public function index()
{
$products = Product::latest()
->select('id', 'name', 'price', 'stock', 'created_at')
->paginate(10)
->withQueryString();
return Inertia::render('Products/Index', [
'products' => $products,
]);
}
public function create()
{
return Inertia::render('Products/Create');
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'price' => ['required', 'numeric', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
]);
Product::create($validated);
return redirect()
->route('products.index')
->with('success', 'Produk berhasil ditambahkan.');
}
public function edit(Product $product)
{
return Inertia::render('Products/Edit', [
'product' => $product,
]);
}
public function update(Request $request, Product $product)
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'price' => ['required', 'numeric', 'min:0'],
'stock' => ['required', 'integer', 'min:0'],
]);
$product->update($validated);
return redirect()
->route('products.index')
->with('success', 'Produk berhasil diperbarui.');
}
public function destroy(Product $product)
{
$product->delete();
return redirect()
->route('products.index')
->with('success', 'Produk berhasil dihapus.');
}
}Beberapa hal penting dari implementasi di atas:
- Validasi dilakukan di server sehingga tetap aman walau user memodifikasi request dari browser.
- Redirect setelah simpan membuat pola alur aplikasi jelas dan mencegah submit ulang ketika halaman di-refresh.
- Flash message disimpan dalam session menggunakan
with('success', ...). - Route model binding pada parameter
Product $productmembuat kode lebih singkat dan aman.
Membagikan flash message ke Inertia
Agar flash message tersedia di semua halaman Inertia, biasanya kita bagikan lewat middleware, misalnya pada HandleInertiaRequests:
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'flash' => [
'success' => fn () => $request->session()->get('success'),
],
]);
}Dengan begitu, komponen frontend bisa mengakses $page.props.flash.success.
4. Halaman Index: Menampilkan Data dan Aksi Edit/Hapus
Komponen halaman index bertugas menampilkan daftar produk dan menyediakan tombol aksi. Contoh sederhana:
<script setup>
import { Link, router, usePage } from '@inertiajs/vue3'
const props = defineProps({
products: Object,
})
const page = usePage()
function destroy(id) {
if (confirm('Yakin ingin menghapus produk ini?')) {
router.delete(route('products.destroy', id))
}
}
</script>
<template>
<div>
<h2>Daftar Produk</h2>
<div v-if="page.props.flash.success">
{{ page.props.flash.success }}
</div>
<Link :href="route('products.create')">Tambah Produk</Link>
<table>
<thead>
<tr>
<th>Nama</th>
<th>Harga</th>
<th>Stok</th>
<th>Aksi</th>
</tr>
</thead>
<tbody>
<tr v-for="product in products.data" :key="product.id">
<td>{{ product.name }}</td>
<td>{{ product.price }}</td>
<td>{{ product.stock }}</td>
<td>
<Link :href="route('products.edit', product.id)">Edit</Link>
<button @click="destroy(product.id)">Hapus</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>Untuk aksi hapus, kita memakai router.delete(). Ini lebih rapi daripada membuat form HTML tersembunyi sendiri. Pastikan tetap ada konfirmasi sebelum hapus agar tidak terjadi penghapusan tidak sengaja.
5. Halaman Create dan Edit dengan useForm
useForm adalah salah satu fitur paling berguna di Inertia. Ia membantu mengelola state form, pengiriman request, loading state, reset form, dan error validasi. Tanpa useForm, Anda biasanya harus membuat state manual untuk field, errors, dan status submit.
Halaman Create
<script setup>
import { useForm, Link } from '@inertiajs/vue3'
const form = useForm({
name: '',
description: '',
price: '',
stock: 0,
})
function submit() {
form.post(route('products.store'))
}
</script>
<template>
<div>
<h2>Tambah Produk</h2>
<form @submit.prevent="submit">
<div>
<label>Nama</label>
<input v-model="form.name" type="text" />
<div v-if="form.errors.name">{{ form.errors.name }}</div>
</div>
<div>
<label>Deskripsi</label>
<textarea v-model="form.description"></textarea>
<div v-if="form.errors.description">{{ form.errors.description }}</div>
</div>
<div>
<label>Harga</label>
<input v-model="form.price" type="number" step="0.01" />
<div v-if="form.errors.price">{{ form.errors.price }}</div>
</div>
<div>
<label>Stok</label>
<input v-model="form.stock" type="number" />
<div v-if="form.errors.stock">{{ form.errors.stock }}</div>
</div>
<button type="submit" :disabled="form.processing">
{{ form.processing ? 'Menyimpan...' : 'Simpan' }}
</button>
<Link :href="route('products.index')">Kembali</Link>
</form>
</div>
</template>Di sini terlihat manfaat useForm:
form.post()mengirim request POST.form.errorsotomatis berisi error validasi dari Laravel.form.processingbisa dipakai untuk menonaktifkan tombol submit agar tidak dobel klik.
Halaman Edit
Untuk edit, pola yang dipakai hampir sama. Perbedaannya hanya pada data awal dan method request:
<script setup>
import { useForm, Link } from '@inertiajs/vue3'
const props = defineProps({
product: Object,
})
const form = useForm({
name: props.product.name,
description: props.product.description ?? '',
price: props.product.price,
stock: props.product.stock,
})
function submit() {
form.put(route('products.update', props.product.id))
}
</script>
<template>
<div>
<h2>Edit Produk</h2>
<form @submit.prevent="submit">
<div>
<label>Nama</label>
<input v-model="form.name" type="text" />
<div v-if="form.errors.name">{{ form.errors.name }}</div>
</div>
<div>
<label>Deskripsi</label>
<textarea v-model="form.description"></textarea>
<div v-if="form.errors.description">{{ form.errors.description }}</div>
</div>
<div>
<label>Harga</label>
<input v-model="form.price" type="number" step="0.01" />
<div v-if="form.errors.price">{{ form.errors.price }}</div>
</div>
<div>
<label>Stok</label>
<input v-model="form.stock" type="number" />
<div v-if="form.errors.stock">{{ form.errors.stock }}</div>
</div>
<button type="submit" :disabled="form.processing">
{{ form.processing ? 'Memperbarui...' : 'Update' }}
</button>
<Link :href="route('products.index')">Kembali</Link>
</form>
</div>
</template>Karena struktur form create dan edit sangat mirip, dalam aplikasi nyata Anda bisa mengekstraknya menjadi komponen bersama, misalnya ProductForm.vue. Ini membantu mengurangi duplikasi dan membuat perawatan lebih mudah.
6. Kenapa Validasi Server-Side Tetap Penting
Walaupun Anda bisa menambahkan validasi client-side untuk meningkatkan pengalaman pengguna, validasi server-side tetap wajib. Alasannya sederhana: browser ada di sisi pengguna dan tidak bisa dipercaya sepenuhnya. User dapat mem-bypass validasi JavaScript, mengubah request lewat devtools, atau mengirim request langsung dengan tool lain.
Dengan menaruh aturan di controller atau Form Request, aplikasi tetap konsisten. Error validasi akan dikirim kembali ke halaman Inertia dan muncul otomatis di form.errors. Ini membuat integrasi frontend-backend terasa natural tanpa perlu menulis parser error manual.
Untuk proyek yang lebih besar, Anda sebaiknya memindahkan aturan validasi ke Form Request agar controller lebih bersih. Namun untuk tutorial dasar CRUD, validasi langsung di controller masih cukup mudah dipahami.
7. Redirect Setelah Simpan dan Pengalaman Pengguna
Setelah data berhasil disimpan, kita melakukan redirect() ke halaman index. Ini adalah pola yang baik karena:
- User langsung kembali ke daftar data.
- Pesan sukses bisa ditampilkan di halaman tujuan.
- Menghindari masalah resubmit saat user me-refresh halaman form.
Alternatifnya, Anda bisa tetap berada di halaman edit setelah update, terutama jika user sering melakukan perubahan berulang. Pilihan ini tergantung kebutuhan aplikasi. Untuk CRUD admin sederhana, redirect ke index biasanya paling mudah dipahami.
8. Kesalahan Umum dan Tips Debugging
Error validasi tidak muncul
Jika error validasi tidak tampil, periksa beberapa hal:
- Pastikan request dikirim dengan
form.post()atauform.put()dari Inertia. - Pastikan field name di frontend sama persis dengan key validasi di backend.
- Pastikan komponen menampilkan
form.errors.nama_field.
Flash message kosong
Jika pesan sukses tidak muncul, biasanya penyebabnya:
- Controller belum mengirim
with('success', ...). - Middleware Inertia belum membagikan data
flash. - Komponen frontend membaca path props yang salah.
Method PUT atau DELETE tidak bekerja
Pastikan route resource terdaftar dengan benar dan pemanggilan menggunakan helper Inertia yang sesuai. Jika memakai route helper JavaScript, pastikan integrasinya juga sudah benar.
Mass assignment error
Jika muncul error terkait mass assignment, cek kembali properti $fillable di model Product.
9. Penutup
Membangun CRUD produk dengan Laravel dan Inertia.js memberi keseimbangan yang baik antara kenyamanan backend monolitik dan pengalaman frontend modern. Laravel menangani database, validasi, dan redirect, sementara Inertia membuat interaksi halaman lebih halus tanpa perlu memisahkan API dan frontend secara kaku.
Dalam tutorial ini, kita sudah membahas pembuatan migration, model, resource controller, halaman index/create/edit, submit form POST/PUT/DELETE, validasi server-side, penanganan error di komponen, flash message, dan redirect setelah simpan. Poin penting yang sangat membantu adalah penggunaan useForm, karena membuat kode form lebih singkat, konsisten, dan mudah dirawat.
Langkah berikutnya yang bisa Anda tambahkan adalah pagination yang lebih rapi, pencarian produk, filter, komponen form reusable, Form Request untuk validasi, dan otorisasi agar hanya user tertentu yang bisa mengubah data. Dengan fondasi CRUD ini, Anda sudah punya pola yang cukup kuat untuk membangun modul admin lain dengan pendekatan serupa.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!