Anda sudah memasang Laravel, halaman welcome sudah muncul, lalu bingung harus mulai dari mana. Tutorial di internet sering melompat-lompat: sebagian masih memakai gaya Laravel 8, sebagian langsung memakai paket tambahan yang belum Anda butuhkan. Hasilnya, CRUD jalan setengah dan muncul error yang tidak Anda pahami.
Panduan ini membangun CRUD produk lengkap di Laravel 11 secara berurutan: tabel, model, controller, validasi, tampilan, pencarian, dan hapus data. Setiap langkah disertai alasan, supaya Anda bisa mengulang pola yang sama untuk pelanggan, kategori, atau transaksi tanpa menyalin buta.
Persiapan
Pastikan PHP 8.2+, Composer, dan MySQL atau MariaDB sudah terpasang. Jika belum, ikuti panduan setup environment PHP, Composer, dan MySQL. Buat project baru lalu atur database di .env:
composer create-project laravel/laravel toko
cd toko
# ubah DB_CONNECTION=mysql, DB_DATABASE=toko, DB_USERNAME, DB_PASSWORD di .env
php artisan serve
Laravel 11 memakai SQLite sebagai default. Jika Anda ingin MySQL, ubah DB_CONNECTION dan buat database kosongnya terlebih dahulu.
Gambaran Alur CRUD
| Aksi | Method HTTP | URL | Method controller |
|---|---|---|---|
| Daftar produk | GET | /products | index |
| Form tambah | GET | /products/create | create |
| Simpan baru | POST | /products | store |
| Detail | GET | /products/{product} | show |
| Form ubah | GET | /products/{product}/edit | edit |
| Simpan perubahan | PUT/PATCH | /products/{product} | update |
| Hapus | DELETE | /products/{product} | destroy |
Tabel ini adalah konvensi resource Laravel. Mengikutinya membuat satu baris route cukup untuk ketujuh aksi.
Langkah 1: Migration dan Model
php artisan make:model Product -mf
Flag -m membuat migration dan -f membuat factory untuk data uji. Isi migration-nya:
public function up(): void
{
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->unsignedInteger('price');
$table->unsignedInteger('stock')->default(0);
$table->text('description')->nullable();
$table->timestamps();
});
}
php artisan migrate
Kenapa migration, bukan membuat tabel lewat phpMyAdmin? Karena struktur tabel ikut tersimpan di Git. Rekan tim atau server produksi cukup menjalankan migrate untuk mendapatkan struktur yang sama persis. Harga disimpan sebagai bilangan bulat karena Rupiah tidak memakai sen; untuk mata uang dengan desimal, gunakan decimal(12, 2).
Lalu daftarkan kolom yang boleh diisi massal di model:
class Product extends Model
{
use HasFactory;
protected $fillable = ['name', 'price', 'stock', 'description'];
}
$fillable melindungi dari mass assignment: tanpa daftar ini, penyerang bisa menyisipkan field tambahan di form dan mengubah kolom yang tidak seharusnya.
Langkah 2: Controller dan Route
php artisan make:controller ProductController --resource --model=Product
php artisan make:request StoreProductRequest
// routes/web.php
use App\Http\Controllers\ProductController;
Route::resource('products', ProductController::class);
Jalankan php artisan route:list --path=products untuk melihat ketujuh route yang terbentuk.
Langkah 3: Validasi dengan Form Request
// app/Http/Requests/StoreProductRequest.php
public function authorize(): bool
{
return true; // ganti dengan pengecekan hak akses bila perlu
}
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'price' => 'required|integer|min:0',
'stock' => 'required|integer|min:0',
'description' => 'nullable|string|max:2000',
];
}
Method authorize() bawaan mengembalikan false. Jika lupa diubah, setiap submit akan berakhir dengan error 403. Kelebihan Form Request: aturan validasi dipakai ulang untuk store dan update, dan controller tetap pendek.
Langkah 4: Isi Controller
public function index(Request $request)
{
$products = Product::query()
->when($request->q, fn ($query, $q) => $query->where('name', 'like', "%{$q}%"))
->latest()
->paginate(10)
->withQueryString();
return view('products.index', compact('products'));
}
public function create()
{
return view('products.create');
}
public function store(StoreProductRequest $request)
{
Product::create($request->validated());
return redirect()->route('products.index')->with('success', 'Produk ditambahkan.');
}
public function edit(Product $product)
{
return view('products.edit', compact('product'));
}
public function update(StoreProductRequest $request, Product $product)
{
$product->update($request->validated());
return redirect()->route('products.index')->with('success', 'Produk diperbarui.');
}
public function destroy(Product $product)
{
$product->delete();
return back()->with('success', 'Produk dihapus.');
}
Parameter Product $product adalah route model binding: Laravel mencari produk berdasarkan ID di URL dan otomatis mengembalikan 404 jika tidak ditemukan. $request->validated() hanya berisi field yang lolos aturan, sehingga input liar tidak ikut tersimpan. withQueryString() menjaga kata kunci pencarian tetap terbawa saat pindah halaman.
Langkah 5: Tampilan Blade
Form tambah dan ubah hampir identik, jadi cukup satu partial resources/views/products/_form.blade.php:
@csrf
<label>Nama</label>
<input name="name" value="{{ old('name', $product->name ?? '') }}">
@error('name') <small>{{ $message }}</small> @enderror
<label>Harga</label>
<input type="number" name="price" value="{{ old('price', $product->price ?? '') }}">
@error('price') <small>{{ $message }}</small> @enderror
<label>Stok</label>
<input type="number" name="stock" value="{{ old('stock', $product->stock ?? 0) }}">
<button type="submit">Simpan</button>
Di edit.blade.php, bungkus partial tersebut dengan form yang memalsukan method PUT, karena browser hanya mengenal GET dan POST:
<form method="POST" action="{{ route('products.update', $product) }}">
@method('PUT')
@include('products._form')
</form>
Untuk tombol hapus di halaman daftar:
<form method="POST" action="{{ route('products.destroy', $product) }}"
onsubmit="return confirm('Hapus produk ini?')">
@csrf
@method('DELETE')
<button type="submit">Hapus</button>
</form>
Fungsi old() mengembalikan isian sebelumnya saat validasi gagal, sehingga pengguna tidak perlu mengetik ulang. Tampilkan {{ $products->links() }} di bawah tabel untuk navigasi halaman.
Langkah 6: Halaman Daftar dan Pesan Sukses
Halaman index.blade.php menampilkan pesan dari session, form pencarian, dan tabel produk:
@if (session('success'))
<div class="alert">{{ session('success') }}</div>
@endif
<form method="GET" action="{{ route('products.index') }}">
<input name="q" value="{{ request('q') }}" placeholder="Cari produk">
</form>
<table>
@forelse ($products as $product)
<tr>
<td>{{ $product->name }}</td>
<td>Rp{{ number_format($product->price, 0, ',', '.') }}</td>
<td>{{ $product->stock }}</td>
<td><a href="{{ route('products.edit', $product) }}">Ubah</a></td>
</tr>
@empty
<tr><td colspan="4">Belum ada produk.</td></tr>
@endforelse
</table>
{{ $products->links() }}
@forelse menangani kondisi tabel kosong sekaligus, sehingga pengguna baru tidak melihat halaman putih tanpa penjelasan. Kurung kurawal ganda {{ }} otomatis meng-escape HTML, jadi nama produk berisi tag script tidak akan dieksekusi.
Langkah 7: Isi Data Contoh dengan Factory
Menguji pagination dengan dua produk tidak akan memperlihatkan masalah. Isi factory yang dibuat di langkah 1:
// database/factories/ProductFactory.php
public function definition(): array
{
return [
'name' => fake()->words(2, true),
'price' => fake()->numberBetween(5000, 250000),
'stock' => fake()->numberBetween(0, 100),
];
}
Lalu jalankan php artisan tinker dan ketik Product::factory()->count(50)->create();. Sekarang Anda bisa mencoba pencarian dan perpindahan halaman dengan data yang lebih realistis.
Error Umum dan Cara Mengatasinya
| Pesan error | Penyebab | Perbaikan |
|---|---|---|
| 419 Page Expired | Form tanpa @csrf atau session kedaluwarsa | Tambahkan @csrf di setiap form POST |
| MassAssignmentException | Kolom belum ada di $fillable | Tambahkan nama kolom ke $fillable |
| 403 This action is unauthorized | authorize() di Form Request masih false | Kembalikan true atau logika hak akses |
| 405 Method Not Allowed | Lupa @method('PUT') atau @method('DELETE') | Tambahkan direktif method di form |
| Target class does not exist | Import controller lupa di routes/web.php | Tambahkan baris use yang benar |
| SQLSTATE[HY000] [2002] | Database tidak bisa dihubungi | Cek .env lalu jalankan php artisan config:clear |
Cara membaca pesan error
Saat halaman merah Laravel muncul, jangan langsung menyalin seluruh teks ke mesin pencari. Baca baris pertama untuk jenis error, lalu cari frame pertama di stack trace yang menunjuk file milik Anda di folder app/ atau resources/views/. Biasanya di situlah letak salah ketik, import yang terlupa, atau nama variabel yang tidak dikirim dari controller. File storage/logs/laravel.log juga menyimpan error yang sama bila halaman sudah ditutup.
Checklist CRUD yang Layak Dipakai
- Semua input divalidasi di Form Request, termasuk saat update.
- Setiap form punya
@csrf, form ubah dan hapus punya@method. - Pesan sukses tampil setelah simpan, ubah, dan hapus.
- Daftar data berpaginasi dan bisa dicari.
- Penghapusan meminta konfirmasi.
- Route CRUD berada di dalam middleware
authsebelum dipasang di server.
Langkah Berikutnya
Setelah CRUD tunggal lancar, tambahkan relasi (misalnya produk milik kategori) dan rancang strukturnya lebih dulu dengan panduan merancang ERD sederhana. Kemudian lindungi route dengan autentikasi dan periksa poin-poin di checklist keamanan dasar Laravel sebelum aplikasi dipakai orang lain. Pola tujuh aksi di atas akan Anda temui berulang kali, dari aplikasi kasir hingga sistem inventaris.