CRUD Produk Sistem Kasir Laravel
Diterbitkan 15 September 2026
Pada tahap kelima kita akan membuat CRUD Produk Sistem Kasir Laravel.
Produk merupakan data inti pada sistem kasir karena nantinya digunakan pada:
transaksi penjualan
transaksi pembelian
stok
kartu stok
laporan produk
produk terlaris
stok menipis
nota
Berbeda dari kategori, satuan, pelanggan, dan supplier, data produk mempunyai lebih banyak aturan.
Pada tutorial ini produk mempunyai kolom:
category_id
unit_id
sku
name
purchase_price
selling_price
stock
minimum_stock
is_active
Namun ada satu aturan penting:
stok tidak akan diedit langsung dari form produk.
Perubahan stok akan dilakukan melalui:
pembelian
penjualan
penyesuaian stok
pembatalan transaksi
Tujuannya agar setiap perubahan stok dapat memiliki histori pada:
stock_movements
Setelah tahap ini selesai, administrator sudah dapat mengelola seluruh data produk yang dibutuhkan sebelum masuk ke transaksi penjualan.
Hasil yang Akan Dibuat
Pada tahap ini kita akan membuat:
ProductController
Route:
/products
View:
resources/views/products/index.blade.php
resources/views/products/create.blade.php
resources/views/products/edit.blade.php
Fitur yang akan tersedia:
- daftar produk;
- tambah produk;
- edit produk;
- nonaktifkan atau aktifkan produk;
- hapus produk yang belum pernah dipakai transaksi;
- pencarian berdasarkan SKU dan nama;
- filter kategori;
- filter status aktif;
- pagination;
- validasi SKU unik;
- validasi harga;
- validasi stok minimum;
- tampilan kategori dan satuan;
- indikator stok menipis.
Persiapan
Pastikan Tahap 01 sampai Tahap 04 sudah selesai.
Project harus sudah memiliki:
login
role admin/cashier
middleware role
database
model
relasi
seeder
CRUD kategori
CRUD satuan
CRUD pelanggan
CRUD supplier
Pastikan kategori dan satuan sudah tersedia.
Contoh dari seeder:
Kategori:
Minuman
Makanan
Sembako
Satuan:
Pieces
Botol
Kotak
Kilogram
Masuk ke folder project:
cd kasir-laravel
Jalankan:
php artisan serve
Login sebagai:
admin@example.com
admin12345
1. Mengapa CRUD Produk Dipisahkan?
Pada versi awal tutorial, produk digabung di crud-master-data.
Namun produk mempunyai lebih banyak aturan dibanding master data biasa.
Produk terhubung dengan:
categories
units
sale_details
purchase_details
stock_movements
Produk juga mempunyai:
harga beli
harga jual
stok
stok minimum
status aktif
Karena itu produk lebih tepat dibahas pada tahap tersendiri agar alurnya lebih mudah dipahami.
2. Membuat ProductController
Jalankan:
php artisan make:controller ProductController --resource
Laravel membuat:
app/Http/Controllers/ProductController.php
3. Menambahkan Route Produk
Buka:
routes/web.php
Tambahkan import:
use App\Http\Controllers\ProductController;
Di dalam group admin:
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::resource('products', ProductController::class)->except('show');
Route::patch(
'products/{product}/toggle-status',
[ProductController::class, 'toggleStatus']
)->name('products.toggle-status');
});
Resource route membuat:
GET /products
GET /products/create
POST /products
GET /products/{product}/edit
PUT /products/{product}
DELETE /products/{product}
Route tambahan:
PATCH /products/{product}/toggle-status
digunakan untuk mengaktifkan atau menonaktifkan produk.
4. Memeriksa Route Produk
Jalankan:
php artisan route:list
Pastikan terdapat:
products.index
products.create
products.store
products.edit
products.update
products.destroy
products.toggle-status
5. Menambahkan Menu Produk
Buka layout:
resources/views/layouts/app.blade.php
Tambahkan pada menu admin:
@if(auth()->user()->role === 'admin')
<li class="nav-item">
<a class="nav-link" href="{{ route('products.index') }}">
Produk
</a>
</li>
@endif
Jika menggunakan sidebar, letakkan sesuai struktur sidebar yang digunakan.
6. ProductController Lengkap
Buka:
app/Http/Controllers/ProductController.php
Isi:
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Models\Product;
use App\Models\Unit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class ProductController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->get('q'));
$categoryId = $request->get('category_id');
$status = $request->get('status');
$products = Product::query()
->with(['category', 'unit'])
->when($search !== '', function ($query) use ($search) {
$query->where(function ($query) use ($search) {
$query
->where('sku', 'like', '%' . $search . '%')
->orWhere('name', 'like', '%' . $search . '%');
});
})
->when(
$categoryId !== null && $categoryId !== '',
fn ($query) => $query->where('category_id', $categoryId)
)
->when(
in_array($status, ['active', 'inactive'], true),
function ($query) use ($status) {
$query->where(
'is_active',
$status === 'active'
);
}
)
->orderBy('name')
->paginate(10)
->withQueryString();
$categories = Category::query()
->orderBy('name')
->get();
return view('products.index', compact(
'products',
'categories',
'search',
'categoryId',
'status'
));
}
public function create(): View
{
$categories = Category::query()
->orderBy('name')
->get();
$units = Unit::query()
->orderBy('name')
->get();
return view('products.create', compact(
'categories',
'units'
));
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'category_id' => [
'nullable',
'exists:categories,id',
],
'unit_id' => [
'nullable',
'exists:units,id',
],
'sku' => [
'required',
'string',
'max:50',
'unique:products,sku',
],
'name' => [
'required',
'string',
'max:255',
],
'purchase_price' => [
'required',
'numeric',
'min:0',
],
'selling_price' => [
'required',
'numeric',
'min:0',
],
'minimum_stock' => [
'required',
'integer',
'min:0',
],
'is_active' => [
'nullable',
'boolean',
],
]);
$validated['sku'] = strtoupper(trim($validated['sku']));
$validated['name'] = trim($validated['name']);
$validated['stock'] = 0;
$validated['is_active'] = $request->boolean('is_active');
Product::create($validated);
return redirect()
->route('products.index')
->with('success', 'Produk berhasil ditambahkan.');
}
public function edit(Product $product): View
{
$categories = Category::query()
->orderBy('name')
->get();
$units = Unit::query()
->orderBy('name')
->get();
return view('products.edit', compact(
'product',
'categories',
'units'
));
}
public function update(
Request $request,
Product $product
): RedirectResponse {
$validated = $request->validate([
'category_id' => [
'nullable',
'exists:categories,id',
],
'unit_id' => [
'nullable',
'exists:units,id',
],
'sku' => [
'required',
'string',
'max:50',
Rule::unique('products', 'sku')
->ignore($product->id),
],
'name' => [
'required',
'string',
'max:255',
],
'purchase_price' => [
'required',
'numeric',
'min:0',
],
'selling_price' => [
'required',
'numeric',
'min:0',
],
'minimum_stock' => [
'required',
'integer',
'min:0',
],
'is_active' => [
'nullable',
'boolean',
],
]);
$validated['sku'] = strtoupper(trim($validated['sku']));
$validated['name'] = trim($validated['name']);
$validated['is_active'] = $request->boolean('is_active');
$product->update($validated);
return redirect()
->route('products.index')
->with('success', 'Produk berhasil diperbarui.');
}
public function destroy(Product $product): RedirectResponse
{
$usedInSales = $product->saleDetails()->exists();
$usedInPurchases = $product->purchaseDetails()->exists();
$hasStockMovements = $product->stockMovements()->exists();
if (
$usedInSales ||
$usedInPurchases ||
$hasStockMovements
) {
return redirect()
->route('products.index')
->with(
'error',
'Produk tidak dapat dihapus karena sudah memiliki histori transaksi atau stok. Nonaktifkan produk jika tidak digunakan lagi.'
);
}
$product->delete();
return redirect()
->route('products.index')
->with('success', 'Produk berhasil dihapus.');
}
public function toggleStatus(Product $product): RedirectResponse
{
$product->update([
'is_active' => ! $product->is_active,
]);
return redirect()
->route('products.index')
->with(
'success',
$product->is_active
? 'Produk berhasil diaktifkan.'
: 'Produk berhasil dinonaktifkan.'
);
}
}
7. Penjelasan Eager Loading
Pada index kita menggunakan:
->with(['category', 'unit'])
Tujuannya agar data kategori dan satuan dimuat bersama produk.
Di Blade nanti kita akan menampilkan:
$product->category?->name
$product->unit?->symbol
Tanpa eager loading, aplikasi dapat menjalankan query tambahan untuk setiap produk.
Dengan eager loading, query menjadi lebih efisien.
8. Pencarian Produk
Kita menggunakan:
->where('sku', 'like', '%' . $search . '%')
->orWhere('name', 'like', '%' . $search . '%');
Pengguna dapat mencari:
BRG-001
Air Mineral
Kopi
Mie
Input pencarian menggunakan parameter:
q
Contoh:
/products?q=kopi
9. Filter Kategori
Filter kategori menggunakan:
->where('category_id', $categoryId)
Contoh URL:
/products?category_id=1
Kita hanya menerapkan filter jika:
category_id
benar-benar diisi.
10. Filter Status Produk
Status menggunakan:
active
inactive
Kemudian diubah menjadi boolean:
$status === 'active'
Contoh:
/products?status=active
atau:
/products?status=inactive
11. Validasi category_id dan unit_id
Validasi:
'exists:categories,id'
dan:
'exists:units,id'
memastikan ID yang dikirim benar-benar tersedia di database.
Pengguna tidak boleh mengirim ID kategori atau satuan yang tidak ada.
12. Validasi SKU
SKU menggunakan:
'unique:products,sku'
Pada update:
Rule::unique('products', 'sku')
->ignore($product->id)
Artinya SKU harus unik, tetapi produk yang sedang diedit tidak dianggap konflik dengan dirinya sendiri.
13. Mengubah SKU Menjadi Huruf Besar
Sebelum disimpan:
$validated['sku'] = strtoupper(trim($validated['sku']));
Contoh input:
brg-010
akan disimpan:
BRG-010
Ini membantu menjaga format SKU lebih konsisten.
14. Stok Produk Baru Selalu 0
Pada method store():
$validated['stock'] = 0;
Artinya produk baru tidak dapat langsung diberi stok dari form CRUD.
Mengapa?
Karena jika pengguna mengisi:
stock = 100
langsung pada form produk, tidak ada histori mengapa stok menjadi 100.
Pada tutorial ini stok baru akan bertambah melalui:
pembelian
penyesuaian stok
Dengan demikian perubahan stok dapat dicatat di:
stock_movements
15. Harga Beli dan Harga Jual
Validasi:
'numeric',
'min:0',
digunakan untuk:
purchase_price
selling_price
Nilai negatif tidak diperbolehkan.
Contoh valid:
0
2500
10000
150000
Harga beli dapat berubah setelah transaksi pembelian.
Pada tahap pembelian nanti harga beli terakhir juga dapat diperbarui dari transaksi terbaru.
16. Stok Minimum
minimum_stock digunakan untuk indikator stok menipis.
Contoh:
stock = 4
minimum_stock = 5
Produk dapat ditandai:
Stok Menipis
Jika:
stock = 10
minimum_stock = 5
stok masih dianggap aman.
17. Status Aktif
Produk mempunyai:
is_active
Produk aktif:
true
Produk tidak aktif:
false
Produk tidak aktif tetap ada di database.
Cara ini lebih aman daripada menghapus produk yang sudah mempunyai histori transaksi.
18. Mengapa Product Tidak Langsung Dihapus?
Produk dapat berhubungan dengan:
sale_details
purchase_details
stock_movements
Jika produk sudah mempunyai histori, penghapusan fisik dapat merusak referensi transaksi.
Karena itu controller melakukan pengecekan:
$product->saleDetails()->exists();
$product->purchaseDetails()->exists();
$product->stockMovements()->exists();
Jika salah satu ada, produk tidak boleh dihapus.
Gunakan:
Nonaktifkan
sebagai pengganti delete.
19. Membuat Folder View Product
Buat folder:
resources/views/products
Buat file:
index.blade.php
create.blade.php
edit.blade.php
20. View Index Product
Buat:
resources/views/products/index.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Produk')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h4 mb-1">Produk</h1>
<p class="text-muted mb-0">
Kelola produk sistem kasir.
</p>
</div>
<a
href="{{ route('products.create') }}"
class="btn btn-primary">
Tambah Produk
</a>
</div>
<div class="card mb-3">
<div class="card-body">
<form
method="GET"
action="{{ route('products.index') }}"
class="row g-2">
<div class="col-lg-4">
<input
type="text"
name="q"
class="form-control"
placeholder="Cari SKU atau nama produk..."
value="{{ $search }}">
</div>
<div class="col-lg-3">
<select
name="category_id"
class="form-select">
<option value="">
Semua Kategori
</option>
@foreach($categories as $category)
<option
value="{{ $category->id }}"
@selected((string) $categoryId === (string) $category->id)>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
<div class="col-lg-2">
<select
name="status"
class="form-select">
<option value="">
Semua Status
</option>
<option
value="active"
@selected($status === 'active')}>
Aktif
</option>
<option
value="inactive"
@selected($status === 'inactive')}>
Tidak Aktif
</option>
</select>
</div>
<div class="col-lg-3">
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-outline-primary">
Filter
</button>
<a
href="{{ route('products.index') }}"
class="btn btn-outline-secondary">
Reset
</a>
</div>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th>No</th>
<th>SKU</th>
<th>Produk</th>
<th>Kategori</th>
<th>Satuan</th>
<th class="text-end">Harga Beli</th>
<th class="text-end">Harga Jual</th>
<th class="text-center">Stok</th>
<th>Status</th>
<th style="min-width: 220px;">Aksi</th>
</tr>
</thead>
<tbody>
@forelse($products as $product)
<tr>
<td>
{{ $products->firstItem() + $loop->index }}
</td>
<td>
<code>{{ $product->sku }}</code>
</td>
<td>
<div class="fw-semibold">
{{ $product->name }}
</div>
@if($product->stock <= $product->minimum_stock)
<span class="badge text-bg-warning">
Stok Menipis
</span>
@endif
</td>
<td>
{{ $product->category?->name ?? '-' }}
</td>
<td>
{{ $product->unit?->symbol ?? '-' }}
</td>
<td class="text-end">
Rp {{ number_format(
(float) $product->purchase_price,
0,
',',
'.'
) }}
</td>
<td class="text-end">
Rp {{ number_format(
(float) $product->selling_price,
0,
',',
'.'
) }}
</td>
<td class="text-center">
{{ $product->stock }}
@if($product->unit)
{{ $product->unit->symbol }}
@endif
</td>
<td>
@if($product->is_active)
<span class="badge text-bg-success">
Aktif
</span>
@else
<span class="badge text-bg-secondary">
Tidak Aktif
</span>
@endif
</td>
<td>
<div class="d-flex flex-wrap gap-2">
<a
href="{{ route('products.edit', $product) }}"
class="btn btn-sm btn-warning">
Edit
</a>
<form
action="{{ route('products.toggle-status', $product) }}"
method="POST">
@csrf
@method('PATCH')
<button
type="submit"
class="btn btn-sm btn-outline-secondary">
{{ $product->is_active
? 'Nonaktifkan'
: 'Aktifkan' }}
</button>
</form>
<form
action="{{ route('products.destroy', $product) }}"
method="POST"
onsubmit="return confirm('Hapus produk ini?')">
@csrf
@method('DELETE')
<button
type="submit"
class="btn btn-sm btn-danger">
Hapus
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td
colspan="10"
class="text-center text-muted">
Data produk belum tersedia.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $products->links() }}
</div>
</div>
@endsection
21. Indikator Stok Menipis
Pada tabel produk:
@if($product->stock <= $product->minimum_stock)
<span class="badge text-bg-warning">
Stok Menipis
</span>
@endif
Contoh:
stock = 5
minimum_stock = 5
akan ditandai stok menipis.
Begitu juga:
stock = 3
minimum_stock = 5
22. View Create Product
Buat:
resources/views/products/create.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Tambah Produk')
@section('content')
<div class="row justify-content-center">
<div class="col-xl-9">
<div class="card">
<div class="card-header">
Tambah Produk
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('products.store') }}">
@csrf
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
SKU
</label>
<input
type="text"
name="sku"
value="{{ old('sku') }}"
class="form-control @error('sku') is-invalid @enderror"
maxlength="50"
required
autofocus>
@error('sku')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
<div class="form-text">
Contoh: BRG-004
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
Nama Produk
</label>
<input
type="text"
name="name"
value="{{ old('name') }}"
class="form-control @error('name') is-invalid @enderror"
required>
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
Kategori
</label>
<select
name="category_id"
class="form-select @error('category_id') is-invalid @enderror">
<option value="">
Pilih Kategori
</option>
@foreach($categories as $category)
<option
value="{{ $category->id }}"
@selected(
(string) old('category_id')
===
(string) $category->id
)>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
Satuan
</label>
<select
name="unit_id"
class="form-select @error('unit_id') is-invalid @enderror">
<option value="">
Pilih Satuan
</option>
@foreach($units as $unit)
<option
value="{{ $unit->id }}"
@selected(
(string) old('unit_id')
===
(string) $unit->id
)>
{{ $unit->name }}
({{ $unit->symbol }})
</option>
@endforeach
</select>
@error('unit_id')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">
Harga Beli
</label>
<input
type="number"
name="purchase_price"
value="{{ old('purchase_price', 0) }}"
class="form-control @error('purchase_price') is-invalid @enderror"
min="0"
step="0.01"
required>
@error('purchase_price')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="col-md-4 mb-3">
<label class="form-label">
Harga Jual
</label>
<input
type="number"
name="selling_price"
value="{{ old('selling_price', 0) }}"
class="form-control @error('selling_price') is-invalid @enderror"
min="0"
step="0.01"
required>
@error('selling_price')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="col-md-4 mb-3">
<label class="form-label">
Stok Minimum
</label>
<input
type="number"
name="minimum_stock"
value="{{ old('minimum_stock', 0) }}"
class="form-control @error('minimum_stock') is-invalid @enderror"
min="0"
step="1"
required>
@error('minimum_stock')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
</div>
<div class="alert alert-info">
Stok awal produk baru adalah 0.
Tambahkan stok melalui transaksi pembelian
atau penyesuaian stok agar perubahan stok
mempunyai histori.
</div>
<div class="form-check mb-3">
<input
type="checkbox"
name="is_active"
value="1"
class="form-check-input"
id="is_active"
@checked(old('is_active', true))>
<label
class="form-check-label"
for="is_active">
Produk aktif
</label>
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan
</button>
<a
href="{{ route('products.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
23. Mengapa Tidak Ada Input Stock?
Perhatikan bahwa form create tidak mempunyai:
<input name="stock">
Ini disengaja.
Produk baru otomatis:
stock = 0
Nantinya stok pertama dapat dimasukkan melalui pembelian atau penyesuaian stok.
Pendekatan ini menjaga histori stok.
24. View Edit Product
Buat:
resources/views/products/edit.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Edit Produk')
@section('content')
<div class="row justify-content-center">
<div class="col-xl-9">
<div class="card">
<div class="card-header">
Edit Produk
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('products.update', $product) }}">
@csrf
@method('PUT')
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
SKU
</label>
<input
type="text"
name="sku"
value="{{ old('sku', $product->sku) }}"
class="form-control @error('sku') is-invalid @enderror"
maxlength="50"
required>
@error('sku')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
Nama Produk
</label>
<input
type="text"
name="name"
value="{{ old('name', $product->name) }}"
class="form-control @error('name') is-invalid @enderror"
required>
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
Kategori
</label>
<select
name="category_id"
class="form-select @error('category_id') is-invalid @enderror">
<option value="">
Pilih Kategori
</option>
@foreach($categories as $category)
<option
value="{{ $category->id }}"
@selected(
(string) old(
'category_id',
$product->category_id
)
===
(string) $category->id
)>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
Satuan
</label>
<select
name="unit_id"
class="form-select @error('unit_id') is-invalid @enderror">
<option value="">
Pilih Satuan
</option>
@foreach($units as $unit)
<option
value="{{ $unit->id }}"
@selected(
(string) old(
'unit_id',
$product->unit_id
)
===
(string) $unit->id
)>
{{ $unit->name }}
({{ $unit->symbol }})
</option>
@endforeach
</select>
@error('unit_id')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">
Harga Beli
</label>
<input
type="number"
name="purchase_price"
value="{{ old(
'purchase_price',
$product->purchase_price
) }}"
class="form-control @error('purchase_price') is-invalid @enderror"
min="0"
step="0.01"
required>
@error('purchase_price')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="col-md-4 mb-3">
<label class="form-label">
Harga Jual
</label>
<input
type="number"
name="selling_price"
value="{{ old(
'selling_price',
$product->selling_price
) }}"
class="form-control @error('selling_price') is-invalid @enderror"
min="0"
step="0.01"
required>
@error('selling_price')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="col-md-4 mb-3">
<label class="form-label">
Stok Minimum
</label>
<input
type="number"
name="minimum_stock"
value="{{ old(
'minimum_stock',
$product->minimum_stock
) }}"
class="form-control @error('minimum_stock') is-invalid @enderror"
min="0"
step="1"
required>
@error('minimum_stock')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
</div>
<div class="mb-3">
<label class="form-label">
Stok Saat Ini
</label>
<input
type="text"
class="form-control"
value="{{ $product->stock }} {{ $product->unit?->symbol }}"
disabled>
<div class="form-text">
Stok tidak dapat diubah dari form produk.
Gunakan transaksi pembelian atau
penyesuaian stok.
</div>
</div>
<div class="form-check mb-3">
<input
type="checkbox"
name="is_active"
value="1"
class="form-check-input"
id="is_active"
@checked(
old(
'is_active',
$product->is_active
)
)>
<label
class="form-check-label"
for="is_active">
Produk aktif
</label>
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan Perubahan
</button>
<a
href="{{ route('products.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
25. Stok pada Form Edit Hanya Ditampilkan
Kita menampilkan:
<input
type="text"
value="{{ $product->stock }}"
disabled>
Pengguna dapat melihat stok tetapi tidak dapat mengubahnya.
Ini penting agar perubahan stok tidak melewati sistem stok.
26. Checkbox is_active dan boolean()
Pada controller:
$validated['is_active'] = $request->boolean('is_active');
Jika checkbox dicentang:
true
Jika tidak dicentang:
false
Ini lebih aman daripada mengandalkan nilai string langsung.
27. Menguji Tambah Produk
Buka:
/products/create
Masukkan:
SKU : BRG-004
Nama Produk : Teh Botol
Kategori : Minuman
Satuan : Botol
Harga Beli : 3000
Harga Jual : 5000
Stok Minimum : 5
Aktif : Ya
Simpan.
Produk harus tersimpan dengan:
stock = 0
28. Memeriksa Produk dengan Tinker
Jalankan:
php artisan tinker
Kemudian:
$product = App\Models\Product::where('sku', 'BRG-004')->first();
Cek:
$product->stock;
Hasil:
0
Cek kategori:
$product->category?->name;
Contoh:
Minuman
Cek satuan:
$product->unit?->symbol;
Contoh:
btl
Keluar:
exit
29. Menguji SKU Duplikat
Coba buat produk baru dengan:
SKU = BRG-004
Sistem harus menolak karena SKU sudah digunakan.
Validasi berasal dari:
'unique:products,sku'
30. Menguji SKU Huruf Kecil
Masukkan:
brg-005
Setelah disimpan, cek daftar produk.
Nilai harus menjadi:
BRG-005
karena controller menggunakan:
strtoupper(...)
31. Menguji Harga Negatif
Masukkan:
purchase_price = -1000
atau:
selling_price = -1000
Validasi harus menolak.
32. Menguji Stok Minimum Negatif
Masukkan:
minimum_stock = -1
Validasi harus menolak karena:
'min:0'
33. Menguji Filter Kategori
Buka:
/products
Pilih:
Minuman
Klik:
Filter
Daftar hanya menampilkan produk kategori Minuman.
34. Menguji Filter Status
Nonaktifkan salah satu produk.
Kemudian pilih:
Tidak Aktif
Daftar harus hanya menampilkan produk tidak aktif.
35. Menguji Pencarian
Cari:
Kopi
atau:
BRG-002
Pencarian harus bekerja berdasarkan:
SKU
nama produk
36. Menguji Kombinasi Filter
Kita dapat menggunakan sekaligus:
q
category_id
status
Contoh:
q = kopi
category = Minuman
status = active
Karena menggunakan:
withQueryString()
filter tetap dipertahankan saat berpindah halaman.
37. Menguji Edit Produk
Edit:
Teh Botol
Ubah:
Harga Jual = 5500
Stok Minimum = 10
Simpan.
Pastikan stok tetap tidak berubah.
38. Menguji Toggle Status
Pada daftar produk klik:
Nonaktifkan
Status menjadi:
Tidak Aktif
Klik:
Aktifkan
Status kembali:
Aktif
39. Produk Tidak Aktif dan Transaksi
Pada tahap penjualan nanti, hanya produk dengan:
is_active = true
yang boleh dipilih di halaman kasir.
Jadi produk tidak aktif:
- tetap tersimpan;
- histori tetap aman;
- tidak muncul sebagai produk yang dapat dijual.
40. Menguji Delete Produk Baru
Produk baru dengan:
stock = 0
dan belum mempunyai:
sale_details
purchase_details
stock_movements
dapat dihapus.
Coba buat produk sementara lalu hapus.
41. Menguji Delete Produk Seeder
Produk dari seeder mempunyai:
stock_movements
berupa stok awal.
Karena itu ketika mencoba menghapus produk seeder, sistem harus menolak.
Pesan:
Produk tidak dapat dihapus karena sudah memiliki histori transaksi atau stok. Nonaktifkan produk jika tidak digunakan lagi.
Ini merupakan perilaku yang benar.
42. Mengapa Produk Seeder Tidak Bisa Dihapus?
Pada Tahap 03 kita membuat stok awal dan:
stock_movements
Produk tersebut sudah mempunyai histori stok.
Walaupun belum mempunyai transaksi penjualan atau pembelian, histori stok sudah cukup menjadi alasan untuk mempertahankan data.
43. Menonaktifkan Lebih Aman daripada Menghapus
Misalnya produk:
Kopi Sachet
tidak dijual lagi.
Jangan hapus jika sudah mempunyai histori.
Gunakan:
is_active = false
Histori transaksi tetap tersedia.
44. Menampilkan Produk Aktif Saja di POS
Pada tahap transaksi penjualan nanti kita akan menggunakan query seperti:
Product::query()
->where('is_active', true)
->where('stock', '>', 0)
->orderBy('name')
->get();
Produk tidak aktif tidak ikut ditampilkan.
45. Menampilkan Produk Stok Menipis
Query:
Product::query()
->whereColumn('stock', '<=', 'minimum_stock')
->orderBy('stock')
->get();
dapat digunakan untuk dashboard.
Konsep tersebut sudah terlihat dari badge:
Stok Menipis
pada daftar produk.
46. Menampilkan Produk Habis
Jika ingin mengambil produk dengan stok:
0
gunakan:
Product::where('stock', 0)->get();
Pada sistem kasir nanti produk dengan stok 0 tidak boleh dijual.
47. Harga Jual Tidak Boleh Dipercaya dari Browser
Form CRUD memang dapat mengubah:
selling_price
Namun saat transaksi penjualan nanti, browser hanya mengirim:
product_id
quantity
Harga jual akan diambil kembali dari database.
Jangan mempercayai:
price
subtotal
grand_total
yang dikirim browser.
Ini penting agar pengguna tidak dapat memanipulasi harga melalui developer tools.
48. Menambahkan Helper Format Rupiah Opsional
Saat ini kita menggunakan:
number_format(
(float) $product->selling_price,
0,
',',
'.'
)
Jika digunakan berulang kali, nantinya dapat dibuat helper khusus.
Namun untuk tahap ini kita tetap menggunakan number_format() agar alur tutorial sederhana.
49. Mengapa Harga Menggunakan decimal?
Pada migration kita membuat:
decimal('purchase_price', 15, 2)
decimal('selling_price', 15, 2)
Model menggunakan cast:
'purchase_price' => 'decimal:2',
'selling_price' => 'decimal:2',
Ini digunakan untuk nilai uang yang membutuhkan presisi.
50. Mengapa Kategori dan Satuan Nullable?
Pada database:
category_id
unit_id
dibuat nullable.
Artinya produk secara teknis dapat dibuat tanpa kategori atau satuan.
Namun untuk penggunaan nyata, sebaiknya admin tetap memilih keduanya agar data lebih rapi.
Tutorial mempertahankan nullable agar produk tidak hilang ketika kategori atau satuan lama dihapus.
51. Jika Ingin Kategori Wajib
Jika kebutuhan bisnis mengharuskan kategori wajib, controller dapat diubah dari:
'nullable'
menjadi:
'required'
Namun migration juga idealnya disesuaikan agar struktur database konsisten.
Untuk tutorial ini kita mengikuti struktur database Tahap 02.
52. Jika Ingin Satuan Wajib
Konsepnya sama.
Saat ini:
'unit_id' => [
'nullable',
'exists:units,id',
],
Jika wajib:
'unit_id' => [
'required',
'exists:units,id',
],
53. Mengapa Tidak Ada Barcode?
Tutorial dasar ini menggunakan:
SKU
sebagai kode produk.
Barcode dapat ditambahkan sebagai pengembangan berikutnya.
Misalnya migration baru:
php artisan make:migration add_barcode_to_products_table --table=products
Tetapi fitur tersebut belum diperlukan agar sistem kasir dasar berfungsi.
54. Mengapa Tidak Ada Gambar Produk?
Untuk sistem kasir sederhana, gambar bukan fitur inti.
Gambar dapat ditambahkan nanti jika ingin membuat POS visual.
Fokus tutorial ini adalah:
data produk
transaksi
stok
laporan
nota
55. Struktur File Setelah Tahap 05
Controller:
app/Http/Controllers/ProductController.php
View:
resources/views/products/
├── create.blade.php
├── edit.blade.php
└── index.blade.php
Route:
products.index
products.create
products.store
products.edit
products.update
products.destroy
products.toggle-status
56. Checklist Pengujian Tahap 05
Pastikan seluruh poin berikut bekerja.
Daftar Produk
[ ] halaman /products dapat dibuka
[ ] kategori tampil
[ ] satuan tampil
[ ] harga tampil
[ ] stok tampil
[ ] status tampil
[ ] pagination tampil
Tambah Produk
[ ] produk dapat ditambah
[ ] SKU wajib
[ ] SKU unik
[ ] nama wajib
[ ] kategori valid
[ ] satuan valid
[ ] harga beli tidak negatif
[ ] harga jual tidak negatif
[ ] stok minimum tidak negatif
[ ] stok awal otomatis 0
Edit Produk
[ ] produk dapat diedit
[ ] SKU lama tetap boleh digunakan produk yang sama
[ ] stok tidak dapat diedit dari form
[ ] harga dapat diperbarui
[ ] status aktif dapat diperbarui
Pencarian dan Filter
[ ] pencarian SKU bekerja
[ ] pencarian nama bekerja
[ ] filter kategori bekerja
[ ] filter aktif bekerja
[ ] filter tidak aktif bekerja
[ ] kombinasi filter bekerja
[ ] pagination mempertahankan filter
Delete
[ ] produk tanpa histori dapat dihapus
[ ] produk dengan sale detail tidak dapat dihapus
[ ] produk dengan purchase detail tidak dapat dihapus
[ ] produk dengan stock movement tidak dapat dihapus
Status
[ ] produk dapat dinonaktifkan
[ ] produk dapat diaktifkan kembali
57. Kesalahan Umum: SKU Selalu Dianggap Duplikat Saat Edit
Pastikan update menggunakan:
Rule::unique('products', 'sku')
->ignore($product->id)
Jika hanya menggunakan:
'unique:products,sku'
SKU produk sendiri akan dianggap duplikat.
58. Kesalahan Umum: Category atau Unit Undefined
Pastikan method:
create()
dan:
edit()
mengirim:
$categories
$units
ke view.
59. Kesalahan Umum: Class Rule Tidak Ditemukan
Pastikan import:
use Illuminate\Validation\Rule;
ada pada:
ProductController.php
60. Kesalahan Umum: Filter Status Tidak Bekerja
Pastikan option menggunakan:
active
inactive
dan controller memeriksa:
in_array($status, ['active', 'inactive'], true)
61. Kesalahan Umum: Checkbox Tidak Dicentang Menjadi Null
Gunakan:
$request->boolean('is_active')
Jangan langsung mengandalkan:
$request->is_active
karena checkbox yang tidak dicentang biasanya tidak dikirim browser.
62. Kesalahan Umum: Stok Bisa Dimanipulasi dari Request
Pastikan stock tidak ikut dalam validation array dari form create/update.
Walaupun seseorang menambahkan:
stock=9999
secara manual pada request, controller tidak mengambil field tersebut dari $validated.
Pada store() controller bahkan secara eksplisit menetapkan:
$validated['stock'] = 0;
Pada update() stok sama sekali tidak diperbarui.
63. Kesalahan Umum: Produk Berhistori Masih Bisa Dihapus
Pastikan method destroy memeriksa:
$product->saleDetails()->exists()
$product->purchaseDetails()->exists()
$product->stockMovements()->exists()
Jika salah satu true, delete harus ditolak.
64. Kesalahan Umum: Kategori Hilang tetapi Produk Error
Karena migration menggunakan:
nullOnDelete
kategori atau satuan dapat menjadi NULL.
Di Blade gunakan:
$product->category?->name ?? '-'
bukan:
$product->category->name
agar tidak error ketika relasi tidak tersedia.
65. Kesalahan Umum: Pagination Hilang Saat Filter
Pastikan query diakhiri:
->paginate(10)
->withQueryString();
66. Kesalahan Umum: Produk Tidak Muncul Setelah Ditambah
Periksa filter yang sedang aktif.
Contoh jika filter:
status=inactive
tetapi produk baru aktif, produk baru tidak tampil.
Klik:
Reset
untuk menghapus filter.
67. Kesalahan Umum: Harga Tampil dengan Desimal .00
Karena model menggunakan:
decimal:2
nilai dapat berupa:
5000.00
Untuk tampilan rupiah gunakan:
number_format((float) $value, 0, ',', '.')
68. Pengujian Akses Kasir
Logout admin.
Login:
kasir@example.com
kasir12345
Coba buka:
/products
Karena route berada di:
role:admin
kasir tidak boleh mengelola produk.
69. Pengujian Tanpa Login
Logout.
Buka:
/products
Pengguna harus diminta login karena route menggunakan:
auth
70. Checkpoint Hasil
Setelah Tahap 05 selesai:
Login : selesai
Role admin/kasir : selesai
Middleware role : selesai
Database : selesai
Model dan relasi : selesai
Seeder : selesai
CRUD kategori : selesai
CRUD satuan : selesai
CRUD pelanggan : selesai
CRUD supplier : selesai
CRUD produk : selesai
Filter produk : selesai
Status produk : selesai
Proteksi stok dari CRUD : selesai
Transaksi penjualan : tahap berikutnya
Pembelian : belum
Manajemen stok : belum
Dashboard : belum
Laporan : belum
Nota : belum
Testing/deploy : belum
71. Ringkasan
Pada tahap kelima kita telah membuat CRUD produk sistem kasir.
Fitur produk yang sudah tersedia:
tambah produk
edit produk
hapus produk aman
aktif/nonaktif produk
pencarian SKU
pencarian nama
filter kategori
filter status
pagination
indikator stok menipis
Validasi sudah mencakup:
SKU unik
kategori valid
satuan valid
harga tidak negatif
stok minimum tidak negatif
Stok sengaja tidak dapat diedit dari CRUD produk.
Produk baru selalu dibuat dengan:
stock = 0
Perubahan stok selanjutnya dilakukan melalui proses transaksi agar riwayat stok tetap dapat ditelusuri.
Tahap berikutnya kita akan membuat fitur paling penting pada sistem kasir, yaitu transaksi penjualan/POS.
Pada tahap tersebut kita akan membuat:
halaman kasir
pencarian produk
keranjang belanja
quantity
subtotal
diskon
pembayaran
kembalian
pengurangan stok
stock movement
detail transaksi
pembatalan transaksi
Harga dan total transaksi akan dihitung kembali di sisi server agar tidak bergantung pada nilai dari browser.




