Bagian 5 dari seri

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.

Lanjut: Transaksi Penjualan Sistem Kasir