CRUD Master Data Sistem Kasir Laravel
Diterbitkan 15 September 2026
Pada tahap keempat kita akan membuat CRUD master data Sistem Kasir Laravel.
CRUD merupakan singkatan dari:
Create
Read
Update
Delete
Pada bagian ini kita akan membuat halaman pengelolaan untuk:
Kategori
Satuan
Pelanggan
Supplier
Produk belum dibahas pada tahap ini karena CRUD produk lebih kompleks. Produk memiliki relasi kategori dan satuan, harga beli, harga jual, stok minimum, status aktif, serta aturan khusus agar stok tidak diubah sembarangan.
Karena itu produk akan dibahas pada tahap berikutnya secara terpisah.
Setelah menyelesaikan tahap ini, administrator sudah dapat:
- melihat daftar kategori;
- menambah kategori;
- mengubah kategori;
- menghapus kategori yang belum terikat data penting;
- melihat daftar satuan;
- menambah satuan;
- mengubah satuan;
- menghapus satuan yang belum terikat data penting;
- melihat daftar pelanggan;
- mencari pelanggan;
- menambah pelanggan;
- mengubah pelanggan;
- menghapus pelanggan yang belum mempunyai transaksi;
- melihat daftar supplier;
- mencari supplier;
- menambah supplier;
- mengubah supplier;
- menghapus supplier yang belum mempunyai transaksi.
Semua halaman master data akan menggunakan layout Bootstrap dari aplikasi.
Hasil yang Akan Dibuat
Pada tahap ini kita akan membuat controller:
CategoryController
UnitController
CustomerController
SupplierController
Route:
/categories
/units
/customers
/suppliers
View:
resources/views/categories
resources/views/units
resources/views/customers
resources/views/suppliers
Setiap master mempunyai halaman:
index
create
edit
Untuk proses penyimpanan, update, dan delete kita menggunakan method controller tanpa view tambahan.
Persiapan
Pastikan Tahap 01 sampai Tahap 03 sudah selesai.
Project harus sudah memiliki:
login
role admin/cashier
middleware role
database
model
relasi
seeder
Pastikan akun admin dapat login:
Email : admin@example.com
Password : admin12345
Masuk ke folder project:
cd kasir-laravel
Jalankan aplikasi:
php artisan serve
1. Mengapa Master Data Hanya untuk Admin?
Data master seperti kategori, satuan, pelanggan, dan supplier memengaruhi banyak bagian aplikasi.
Untuk tutorial ini kita membatasi pengelolaan master data kepada administrator.
Kasir nantinya fokus pada:
transaksi penjualan
melihat data yang diperlukan
Administrator dapat mengelola:
master data
produk
pembelian
stok
laporan
Karena pada Tahap 01 kita sudah membuat middleware role, route master data akan dibungkus dengan:
role:admin
2. Membuat Resource Controller
Jalankan:
php artisan make:controller CategoryController --resource
php artisan make:controller UnitController --resource
php artisan make:controller CustomerController --resource
php artisan make:controller SupplierController --resource
Laravel membuat file:
app/Http/Controllers/CategoryController.php
app/Http/Controllers/UnitController.php
app/Http/Controllers/CustomerController.php
app/Http/Controllers/SupplierController.php
Resource controller menyediakan method:
index
create
store
show
edit
update
destroy
Pada master data ini kita tidak membutuhkan halaman show.
Karena itu route nanti menggunakan:
except('show')
3. Menambahkan Route Master Data
Buka:
routes/web.php
Pastikan import controller tersedia:
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\CustomerController;
use App\Http\Controllers\SupplierController;
use App\Http\Controllers\UnitController;
Tambahkan route di dalam group:
Route::middleware(['auth', 'role:admin'])->group(function () {
Route::resource('categories', CategoryController::class)->except('show');
Route::resource('units', UnitController::class)->except('show');
Route::resource('customers', CustomerController::class)->except('show');
Route::resource('suppliers', SupplierController::class)->except('show');
});
Dengan resource route, Laravel otomatis membuat route:
GET /categories
GET /categories/create
POST /categories
GET /categories/{category}/edit
PUT /categories/{category}
DELETE /categories/{category}
Pola yang sama berlaku untuk:
units
customers
suppliers
4. Memeriksa Route
Jalankan:
php artisan route:list
Cari route:
categories.index
categories.create
categories.store
categories.edit
categories.update
categories.destroy
Lakukan hal yang sama untuk:
units
customers
suppliers
Jika route tidak muncul, periksa:
routes/web.php
5. Membuat Folder View
Buat folder:
resources/views/categories
resources/views/units
resources/views/customers
resources/views/suppliers
Masing-masing akan berisi:
index.blade.php
create.blade.php
edit.blade.php
6. Menambahkan Menu Master Data pada Layout
Buka layout utama aplikasi yang dibuat pada Tahap 01.
Contohnya:
resources/views/layouts/app.blade.php
Pada bagian navigasi admin, tambahkan menu:
@if(auth()->user()->role === 'admin')
<li class="nav-item">
<a class="nav-link" href="{{ route('categories.index') }}">
Kategori
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('units.index') }}">
Satuan
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('customers.index') }}">
Pelanggan
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ route('suppliers.index') }}">
Supplier
</a>
</li>
@endif
Jika layout menggunakan sidebar, letakkan link tersebut pada area sidebar.
Yang penting route menggunakan:
categories.index
units.index
customers.index
suppliers.index
7. Menambahkan Flash Message pada Layout
Agar pesan sukses dapat tampil pada semua halaman, buka:
resources/views/layouts/app.blade.php
Tambahkan sebelum:
@yield('content')
kode:
@if(session('success'))
<div class="alert alert-success alert-dismissible fade show" role="alert">
{{ session('success') }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close">
</button>
</div>
@endif
@if(session('error'))
<div class="alert alert-danger alert-dismissible fade show" role="alert">
{{ session('error') }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close">
</button>
</div>
@endif
Controller nantinya dapat mengirim:
return redirect()
->route('categories.index')
->with('success', 'Kategori berhasil ditambahkan.');
8. CRUD Category
Sekarang kita mulai dari kategori karena strukturnya paling sederhana.
9. CategoryController Lengkap
Buka:
app/Http/Controllers/CategoryController.php
Isi:
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CategoryController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->get('q'));
$categories = Category::query()
->when($search !== '', function ($query) use ($search) {
$query->where('name', 'like', '%' . $search . '%');
})
->orderBy('name')
->paginate(10)
->withQueryString();
return view('categories.index', compact(
'categories',
'search'
));
}
public function create(): View
{
return view('categories.create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
'unique:categories,name',
],
]);
Category::create($validated);
return redirect()
->route('categories.index')
->with('success', 'Kategori berhasil ditambahkan.');
}
public function edit(Category $category): View
{
return view('categories.edit', compact('category'));
}
public function update(
Request $request,
Category $category
): RedirectResponse {
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
'unique:categories,name,' . $category->id,
],
]);
$category->update($validated);
return redirect()
->route('categories.index')
->with('success', 'Kategori berhasil diperbarui.');
}
public function destroy(Category $category): RedirectResponse
{
if ($category->products()->exists()) {
return redirect()
->route('categories.index')
->with(
'error',
'Kategori tidak dapat dihapus karena masih digunakan oleh produk.'
);
}
$category->delete();
return redirect()
->route('categories.index')
->with('success', 'Kategori berhasil dihapus.');
}
}
10. Penjelasan Pencarian Category
Bagian:
$search = trim((string) $request->get('q'));
mengambil query:
q
Contoh URL:
/categories?q=Minuman
Kemudian:
->when($search !== '', function ($query) use ($search) {
$query->where('name', 'like', '%' . $search . '%');
})
hanya menambahkan filter ketika input pencarian tidak kosong.
11. Pagination Category
Kode:
->paginate(10)
menampilkan maksimal 10 kategori per halaman.
Sedangkan:
->withQueryString()
membuat parameter pencarian tetap dipertahankan ketika pengguna berpindah halaman.
12. Validasi Unique Category
Pada store():
'unique:categories,name',
berarti nama kategori tidak boleh duplikat.
Pada update():
'unique:categories,name,' . $category->id,
record yang sedang diedit dikecualikan dari pemeriksaan unique.
Jika kategori:
Minuman
diubah tanpa mengganti namanya, update tetap diperbolehkan.
13. Proteksi Delete Category
Sebelum menghapus:
if ($category->products()->exists()) {
// ...
}
Jika kategori masih digunakan produk, kategori tidak dihapus.
Walaupun migration kita menggunakan:
nullOnDelete
proteksi aplikasi ini lebih ramah pengguna karena admin mendapat informasi bahwa kategori masih digunakan.
Jika memang kategori tidak digunakan lagi, barulah dapat dihapus.
14. View Index Category
Buat:
resources/views/categories/index.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Kategori')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h4 mb-1">Kategori</h1>
<p class="text-muted mb-0">
Kelola kategori produk.
</p>
</div>
<a href="{{ route('categories.create') }}"
class="btn btn-primary">
Tambah Kategori
</a>
</div>
<div class="card">
<div class="card-body">
<form method="GET"
action="{{ route('categories.index') }}"
class="row g-2 mb-3">
<div class="col-md-8">
<input
type="text"
name="q"
class="form-control"
placeholder="Cari kategori..."
value="{{ $search }}">
</div>
<div class="col-md-4">
<div class="d-flex gap-2">
<button class="btn btn-outline-primary">
Cari
</button>
<a href="{{ route('categories.index') }}"
class="btn btn-outline-secondary">
Reset
</a>
</div>
</div>
</form>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th style="width: 70px;">No</th>
<th>Nama Kategori</th>
<th style="width: 180px;">Aksi</th>
</tr>
</thead>
<tbody>
@forelse($categories as $category)
<tr>
<td>
{{ $categories->firstItem() + $loop->index }}
</td>
<td>{{ $category->name }}</td>
<td>
<div class="d-flex gap-2">
<a
href="{{ route('categories.edit', $category) }}"
class="btn btn-sm btn-warning">
Edit
</a>
<form
action="{{ route('categories.destroy', $category) }}"
method="POST"
onsubmit="return confirm('Hapus kategori ini?')">
@csrf
@method('DELETE')
<button
type="submit"
class="btn btn-sm btn-danger">
Hapus
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="3"
class="text-center text-muted">
Data kategori belum tersedia.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $categories->links() }}
</div>
</div>
@endsection
15. Pagination Bootstrap Laravel
Jika pagination tampil dengan style yang tidak sesuai Bootstrap, buka:
app/Providers/AppServiceProvider.php
Tambahkan import:
use Illuminate\Pagination\Paginator;
Kemudian pada method:
public function boot(): void
tambahkan:
Paginator::useBootstrapFive();
Contoh:
<?php
namespace App\Providers;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Paginator::useBootstrapFive();
}
}
Sekarang:
{{ $categories->links() }}
menggunakan pagination Bootstrap 5.
16. View Create Category
Buat:
resources/views/categories/create.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Tambah Kategori')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
Tambah Kategori
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('categories.store') }}">
@csrf
<div class="mb-3">
<label class="form-label">
Nama Kategori
</label>
<input
type="text"
name="name"
value="{{ old('name') }}"
class="form-control @error('name') is-invalid @enderror"
required
autofocus>
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan
</button>
<a
href="{{ route('categories.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
17. View Edit Category
Buat:
resources/views/categories/edit.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Edit Kategori')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
Edit Kategori
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('categories.update', $category) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label class="form-label">
Nama Kategori
</label>
<input
type="text"
name="name"
value="{{ old('name', $category->name) }}"
class="form-control @error('name') is-invalid @enderror"
required
autofocus>
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan Perubahan
</button>
<a
href="{{ route('categories.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
18. Menguji CRUD Category
Login sebagai:
admin@example.com
Buka:
/categories
Uji:
- tambah kategori;
- tambah kategori dengan nama sama;
- edit kategori;
- cari kategori;
- hapus kategori yang belum digunakan.
Jika mencoba membuat nama yang sama, validasi harus menolak.
19. CRUD Unit
Sekarang kita membuat CRUD satuan.
Strukturnya hampir sama dengan kategori, tetapi mempunyai:
name
symbol
Contoh:
Pieces | pcs
Botol | btl
Kotak | box
20. UnitController Lengkap
Buka:
app/Http/Controllers/UnitController.php
Isi:
<?php
namespace App\Http\Controllers;
use App\Models\Unit;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class UnitController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->get('q'));
$units = Unit::query()
->when($search !== '', function ($query) use ($search) {
$query->where(function ($query) use ($search) {
$query
->where('name', 'like', '%' . $search . '%')
->orWhere('symbol', 'like', '%' . $search . '%');
});
})
->orderBy('name')
->paginate(10)
->withQueryString();
return view('units.index', compact(
'units',
'search'
));
}
public function create(): View
{
return view('units.create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
'unique:units,name',
],
'symbol' => [
'required',
'string',
'max:20',
],
]);
Unit::create($validated);
return redirect()
->route('units.index')
->with('success', 'Satuan berhasil ditambahkan.');
}
public function edit(Unit $unit): View
{
return view('units.edit', compact('unit'));
}
public function update(
Request $request,
Unit $unit
): RedirectResponse {
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
'unique:units,name,' . $unit->id,
],
'symbol' => [
'required',
'string',
'max:20',
],
]);
$unit->update($validated);
return redirect()
->route('units.index')
->with('success', 'Satuan berhasil diperbarui.');
}
public function destroy(Unit $unit): RedirectResponse
{
if ($unit->products()->exists()) {
return redirect()
->route('units.index')
->with(
'error',
'Satuan tidak dapat dihapus karena masih digunakan oleh produk.'
);
}
$unit->delete();
return redirect()
->route('units.index')
->with('success', 'Satuan berhasil dihapus.');
}
}
21. View Index Unit
Buat:
resources/views/units/index.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Satuan')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h4 mb-1">Satuan</h1>
<p class="text-muted mb-0">
Kelola satuan produk.
</p>
</div>
<a href="{{ route('units.create') }}"
class="btn btn-primary">
Tambah Satuan
</a>
</div>
<div class="card">
<div class="card-body">
<form method="GET"
action="{{ route('units.index') }}"
class="row g-2 mb-3">
<div class="col-md-8">
<input
type="text"
name="q"
class="form-control"
placeholder="Cari nama atau simbol..."
value="{{ $search }}">
</div>
<div class="col-md-4">
<div class="d-flex gap-2">
<button class="btn btn-outline-primary">
Cari
</button>
<a href="{{ route('units.index') }}"
class="btn btn-outline-secondary">
Reset
</a>
</div>
</div>
</form>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th style="width: 70px;">No</th>
<th>Nama</th>
<th>Simbol</th>
<th style="width: 180px;">Aksi</th>
</tr>
</thead>
<tbody>
@forelse($units as $unit)
<tr>
<td>
{{ $units->firstItem() + $loop->index }}
</td>
<td>{{ $unit->name }}</td>
<td>{{ $unit->symbol }}</td>
<td>
<div class="d-flex gap-2">
<a
href="{{ route('units.edit', $unit) }}"
class="btn btn-sm btn-warning">
Edit
</a>
<form
action="{{ route('units.destroy', $unit) }}"
method="POST"
onsubmit="return confirm('Hapus satuan ini?')">
@csrf
@method('DELETE')
<button
type="submit"
class="btn btn-sm btn-danger">
Hapus
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="4"
class="text-center text-muted">
Data satuan belum tersedia.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $units->links() }}
</div>
</div>
@endsection
22. View Create Unit
Buat:
resources/views/units/create.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Tambah Satuan')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
Tambah Satuan
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('units.store') }}">
@csrf
<div class="mb-3">
<label class="form-label">
Nama Satuan
</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 class="mb-3">
<label class="form-label">
Simbol
</label>
<input
type="text"
name="symbol"
value="{{ old('symbol') }}"
class="form-control @error('symbol') is-invalid @enderror"
maxlength="20"
required>
@error('symbol')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan
</button>
<a
href="{{ route('units.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
23. View Edit Unit
Buat:
resources/views/units/edit.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Edit Satuan')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card">
<div class="card-header">
Edit Satuan
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('units.update', $unit) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label class="form-label">
Nama Satuan
</label>
<input
type="text"
name="name"
value="{{ old('name', $unit->name) }}"
class="form-control @error('name') is-invalid @enderror"
required>
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">
Simbol
</label>
<input
type="text"
name="symbol"
value="{{ old('symbol', $unit->symbol) }}"
class="form-control @error('symbol') is-invalid @enderror"
maxlength="20"
required>
@error('symbol')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan Perubahan
</button>
<a
href="{{ route('units.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
24. Menguji CRUD Unit
Buka:
/units
Uji:
Tambah
Edit
Cari
Hapus
Validasi nama duplikat
Coba data:
Nama : Liter
Simbol : l
25. CRUD Customer
Pelanggan mempunyai data:
name
phone
address
Tidak semua transaksi wajib mempunyai pelanggan.
Pelanggan digunakan ketika kita ingin menyimpan identitas pembeli.
26. CustomerController Lengkap
Buka:
app/Http/Controllers/CustomerController.php
Isi:
<?php
namespace App\Http\Controllers;
use App\Models\Customer;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomerController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->get('q'));
$customers = Customer::query()
->when($search !== '', function ($query) use ($search) {
$query->where(function ($query) use ($search) {
$query
->where('name', 'like', '%' . $search . '%')
->orWhere('phone', 'like', '%' . $search . '%')
->orWhere('address', 'like', '%' . $search . '%');
});
})
->latest()
->paginate(10)
->withQueryString();
return view('customers.index', compact(
'customers',
'search'
));
}
public function create(): View
{
return view('customers.create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
],
'phone' => [
'nullable',
'string',
'max:30',
],
'address' => [
'nullable',
'string',
'max:1000',
],
]);
Customer::create($validated);
return redirect()
->route('customers.index')
->with('success', 'Pelanggan berhasil ditambahkan.');
}
public function edit(Customer $customer): View
{
return view('customers.edit', compact('customer'));
}
public function update(
Request $request,
Customer $customer
): RedirectResponse {
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
],
'phone' => [
'nullable',
'string',
'max:30',
],
'address' => [
'nullable',
'string',
'max:1000',
],
]);
$customer->update($validated);
return redirect()
->route('customers.index')
->with('success', 'Pelanggan berhasil diperbarui.');
}
public function destroy(Customer $customer): RedirectResponse
{
if ($customer->sales()->exists()) {
return redirect()
->route('customers.index')
->with(
'error',
'Pelanggan tidak dapat dihapus karena sudah memiliki transaksi penjualan.'
);
}
$customer->delete();
return redirect()
->route('customers.index')
->with('success', 'Pelanggan berhasil dihapus.');
}
}
27. Mengapa Phone Tidak Unique?
Pada tutorial ini:
phone
tidak dibuat unique.
Alasannya:
- beberapa pelanggan dapat menggunakan nomor keluarga yang sama;
- nomor telepon dapat tidak tersedia;
- sistem kasir sederhana tidak membutuhkan nomor sebagai identitas unik.
Jika bisnis membutuhkan nomor pelanggan unik, aturan tersebut dapat ditambahkan kemudian.
28. View Index Customer
Buat:
resources/views/customers/index.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Pelanggan')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h4 mb-1">Pelanggan</h1>
<p class="text-muted mb-0">
Kelola data pelanggan.
</p>
</div>
<a href="{{ route('customers.create') }}"
class="btn btn-primary">
Tambah Pelanggan
</a>
</div>
<div class="card">
<div class="card-body">
<form method="GET"
action="{{ route('customers.index') }}"
class="row g-2 mb-3">
<div class="col-md-8">
<input
type="text"
name="q"
class="form-control"
placeholder="Cari nama, telepon, atau alamat..."
value="{{ $search }}">
</div>
<div class="col-md-4">
<div class="d-flex gap-2">
<button class="btn btn-outline-primary">
Cari
</button>
<a href="{{ route('customers.index') }}"
class="btn btn-outline-secondary">
Reset
</a>
</div>
</div>
</form>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th style="width: 70px;">No</th>
<th>Nama</th>
<th>Telepon</th>
<th>Alamat</th>
<th style="width: 180px;">Aksi</th>
</tr>
</thead>
<tbody>
@forelse($customers as $customer)
<tr>
<td>
{{ $customers->firstItem() + $loop->index }}
</td>
<td>{{ $customer->name }}</td>
<td>
{{ $customer->phone ?: '-' }}
</td>
<td>
{{ $customer->address ?: '-' }}
</td>
<td>
<div class="d-flex gap-2">
<a
href="{{ route('customers.edit', $customer) }}"
class="btn btn-sm btn-warning">
Edit
</a>
<form
action="{{ route('customers.destroy', $customer) }}"
method="POST"
onsubmit="return confirm('Hapus pelanggan ini?')">
@csrf
@method('DELETE')
<button
type="submit"
class="btn btn-sm btn-danger">
Hapus
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5"
class="text-center text-muted">
Data pelanggan belum tersedia.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $customers->links() }}
</div>
</div>
@endsection
29. View Create Customer
Buat:
resources/views/customers/create.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Tambah Pelanggan')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
Tambah Pelanggan
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('customers.store') }}">
@csrf
<div class="mb-3">
<label class="form-label">
Nama Pelanggan
</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 class="mb-3">
<label class="form-label">
Nomor Telepon
</label>
<input
type="text"
name="phone"
value="{{ old('phone') }}"
class="form-control @error('phone') is-invalid @enderror">
@error('phone')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">
Alamat
</label>
<textarea
name="address"
rows="4"
class="form-control @error('address') is-invalid @enderror">{{ old('address') }}</textarea>
@error('address')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan
</button>
<a
href="{{ route('customers.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
30. View Edit Customer
Buat:
resources/views/customers/edit.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Edit Pelanggan')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
Edit Pelanggan
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('customers.update', $customer) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label class="form-label">
Nama Pelanggan
</label>
<input
type="text"
name="name"
value="{{ old('name', $customer->name) }}"
class="form-control @error('name') is-invalid @enderror"
required>
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">
Nomor Telepon
</label>
<input
type="text"
name="phone"
value="{{ old('phone', $customer->phone) }}"
class="form-control @error('phone') is-invalid @enderror">
@error('phone')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">
Alamat
</label>
<textarea
name="address"
rows="4"
class="form-control @error('address') is-invalid @enderror">{{ old('address', $customer->address) }}</textarea>
@error('address')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan Perubahan
</button>
<a
href="{{ route('customers.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
31. Menguji CRUD Customer
Buka:
/customers
Tambahkan:
Nama : I Made Contoh
Telepon : 081234567800
Alamat : Denpasar
Kemudian uji:
pencarian nama
pencarian telepon
edit
hapus
32. CRUD Supplier
Supplier digunakan pada transaksi pembelian.
Kolomnya:
name
phone
address
Strukturnya mirip pelanggan.
33. SupplierController Lengkap
Buka:
app/Http/Controllers/SupplierController.php
Isi:
<?php
namespace App\Http\Controllers;
use App\Models\Supplier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SupplierController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->get('q'));
$suppliers = Supplier::query()
->when($search !== '', function ($query) use ($search) {
$query->where(function ($query) use ($search) {
$query
->where('name', 'like', '%' . $search . '%')
->orWhere('phone', 'like', '%' . $search . '%')
->orWhere('address', 'like', '%' . $search . '%');
});
})
->latest()
->paginate(10)
->withQueryString();
return view('suppliers.index', compact(
'suppliers',
'search'
));
}
public function create(): View
{
return view('suppliers.create');
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
],
'phone' => [
'nullable',
'string',
'max:30',
],
'address' => [
'nullable',
'string',
'max:1000',
],
]);
Supplier::create($validated);
return redirect()
->route('suppliers.index')
->with('success', 'Supplier berhasil ditambahkan.');
}
public function edit(Supplier $supplier): View
{
return view('suppliers.edit', compact('supplier'));
}
public function update(
Request $request,
Supplier $supplier
): RedirectResponse {
$validated = $request->validate([
'name' => [
'required',
'string',
'max:255',
],
'phone' => [
'nullable',
'string',
'max:30',
],
'address' => [
'nullable',
'string',
'max:1000',
],
]);
$supplier->update($validated);
return redirect()
->route('suppliers.index')
->with('success', 'Supplier berhasil diperbarui.');
}
public function destroy(Supplier $supplier): RedirectResponse
{
if ($supplier->purchases()->exists()) {
return redirect()
->route('suppliers.index')
->with(
'error',
'Supplier tidak dapat dihapus karena sudah memiliki transaksi pembelian.'
);
}
$supplier->delete();
return redirect()
->route('suppliers.index')
->with('success', 'Supplier berhasil dihapus.');
}
}
34. View Index Supplier
Buat:
resources/views/suppliers/index.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Supplier')
@section('content')
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h1 class="h4 mb-1">Supplier</h1>
<p class="text-muted mb-0">
Kelola data pemasok barang.
</p>
</div>
<a href="{{ route('suppliers.create') }}"
class="btn btn-primary">
Tambah Supplier
</a>
</div>
<div class="card">
<div class="card-body">
<form method="GET"
action="{{ route('suppliers.index') }}"
class="row g-2 mb-3">
<div class="col-md-8">
<input
type="text"
name="q"
class="form-control"
placeholder="Cari nama, telepon, atau alamat..."
value="{{ $search }}">
</div>
<div class="col-md-4">
<div class="d-flex gap-2">
<button class="btn btn-outline-primary">
Cari
</button>
<a href="{{ route('suppliers.index') }}"
class="btn btn-outline-secondary">
Reset
</a>
</div>
</div>
</form>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead>
<tr>
<th style="width: 70px;">No</th>
<th>Nama</th>
<th>Telepon</th>
<th>Alamat</th>
<th style="width: 180px;">Aksi</th>
</tr>
</thead>
<tbody>
@forelse($suppliers as $supplier)
<tr>
<td>
{{ $suppliers->firstItem() + $loop->index }}
</td>
<td>{{ $supplier->name }}</td>
<td>
{{ $supplier->phone ?: '-' }}
</td>
<td>
{{ $supplier->address ?: '-' }}
</td>
<td>
<div class="d-flex gap-2">
<a
href="{{ route('suppliers.edit', $supplier) }}"
class="btn btn-sm btn-warning">
Edit
</a>
<form
action="{{ route('suppliers.destroy', $supplier) }}"
method="POST"
onsubmit="return confirm('Hapus supplier ini?')">
@csrf
@method('DELETE')
<button
type="submit"
class="btn btn-sm btn-danger">
Hapus
</button>
</form>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5"
class="text-center text-muted">
Data supplier belum tersedia.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $suppliers->links() }}
</div>
</div>
@endsection
35. View Create Supplier
Buat:
resources/views/suppliers/create.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Tambah Supplier')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
Tambah Supplier
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('suppliers.store') }}">
@csrf
<div class="mb-3">
<label class="form-label">
Nama Supplier
</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 class="mb-3">
<label class="form-label">
Nomor Telepon
</label>
<input
type="text"
name="phone"
value="{{ old('phone') }}"
class="form-control @error('phone') is-invalid @enderror">
@error('phone')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">
Alamat
</label>
<textarea
name="address"
rows="4"
class="form-control @error('address') is-invalid @enderror">{{ old('address') }}</textarea>
@error('address')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan
</button>
<a
href="{{ route('suppliers.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
36. View Edit Supplier
Buat:
resources/views/suppliers/edit.blade.php
Isi:
@extends('layouts.app')
@section('title', 'Edit Supplier')
@section('content')
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
Edit Supplier
</div>
<div class="card-body">
<form
method="POST"
action="{{ route('suppliers.update', $supplier) }}">
@csrf
@method('PUT')
<div class="mb-3">
<label class="form-label">
Nama Supplier
</label>
<input
type="text"
name="name"
value="{{ old('name', $supplier->name) }}"
class="form-control @error('name') is-invalid @enderror"
required>
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">
Nomor Telepon
</label>
<input
type="text"
name="phone"
value="{{ old('phone', $supplier->phone) }}"
class="form-control @error('phone') is-invalid @enderror">
@error('phone')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="mb-3">
<label class="form-label">
Alamat
</label>
<textarea
name="address"
rows="4"
class="form-control @error('address') is-invalid @enderror">{{ old('address', $supplier->address) }}</textarea>
@error('address')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
</div>
<div class="d-flex gap-2">
<button
type="submit"
class="btn btn-primary">
Simpan Perubahan
</button>
<a
href="{{ route('suppliers.index') }}"
class="btn btn-secondary">
Kembali
</a>
</div>
</form>
</div>
</div>
</div>
</div>
@endsection
37. Menguji CRUD Supplier
Buka:
/suppliers
Tambahkan:
Nama : PT Sumber Makmur
Telepon : 081234567899
Alamat : Denpasar
Uji:
tambah
edit
pencarian
hapus
38. Route Model Binding
Perhatikan method:
public function edit(Category $category)
Laravel otomatis mencari data berdasarkan parameter:
{category}
Hal ini disebut:
implicit route model binding
Contoh URL:
/categories/5/edit
Laravel akan mengambil:
Category::findOrFail(5)
secara otomatis.
Konsep yang sama berlaku untuk:
Unit $unit
Customer $customer
Supplier $supplier
39. Mengapa Menggunakan @csrf?
Semua form:
POST
PUT
DELETE
harus menggunakan:
@csrf
Contoh:
<form method="POST">
@csrf
</form>
Laravel menggunakan token CSRF untuk membantu mencegah request form palsu dari website lain.
Jika lupa @csrf, request dapat ditolak dengan status:
419
40. Mengapa Edit Menggunakan @method('PUT')?
HTML form secara native hanya mendukung:
GET
POST
Sedangkan resource route update menggunakan:
PUT
Karena itu kita menggunakan:
@method('PUT')
Laravel akan memperlakukan request tersebut sebagai:
PUT
41. Mengapa Delete Menggunakan @method('DELETE')?
Resource route destroy menggunakan method:
DELETE
Karena HTML form tidak memiliki method DELETE, gunakan:
@method('DELETE')
Contoh:
<form method="POST">
@csrf
@method('DELETE')
</form>
42. Menampilkan Error Validasi
Contoh:
@error('name')
<div class="invalid-feedback">
{{ $message }}
</div>
@enderror
Input juga menggunakan:
@error('name') is-invalid @enderror
Dengan demikian Bootstrap menampilkan tampilan error.
43. Mempertahankan Input Lama
Jika validasi gagal, kita menggunakan:
old('name')
agar pengguna tidak perlu mengisi ulang seluruh form.
Untuk edit:
old('name', $category->name)
Jika validasi belum gagal, nilai database digunakan.
Jika validasi gagal, nilai input terbaru pengguna digunakan.
44. Proteksi Data yang Sudah Digunakan Transaksi
Pada aplikasi kasir, penghapusan data harus lebih hati-hati daripada CRUD sederhana.
Kategori dan satuan dicek melalui:
->products()->exists()
Pelanggan dicek:
->sales()->exists()
Supplier dicek:
->purchases()->exists()
Tujuannya agar histori sistem tidak mudah rusak.
45. Mengapa Tidak Mengandalkan Database Saja?
Database memang sudah mempunyai aturan foreign key.
Namun validasi pada controller memberikan pesan yang lebih mudah dipahami.
Daripada pengguna hanya melihat error database, kita dapat memberi pesan:
Supplier tidak dapat dihapus karena sudah memiliki transaksi pembelian.
Ini meningkatkan pengalaman pengguna.
46. Pengujian Akses Kasir
Logout dari admin.
Login sebagai:
kasir@example.com
kasir12345
Coba buka:
/categories
/units
/customers
/suppliers
Karena route menggunakan:
role:admin
kasir tidak boleh mengakses halaman tersebut.
Perilaku akhir tergantung implementasi middleware Tahap 01, misalnya:
403 Forbidden
atau redirect sesuai middleware yang dibuat.
Yang penting kasir tidak mendapat akses master data.
47. Pengujian Akses Tanpa Login
Logout.
Coba buka:
/categories
Karena route juga menggunakan:
auth
pengguna harus diarahkan ke halaman login.
48. Menjaga Query Pencarian Saat Pagination
Pada semua controller kita menggunakan:
->withQueryString()
Misalnya URL:
/customers?q=Made
Ketika berpindah ke halaman 2, Laravel tetap membawa:
?q=Made
Tanpa withQueryString(), filter dapat hilang ketika pagination diklik.
49. Mengubah Jumlah Data per Halaman
Saat ini kita menggunakan:
paginate(10)
Artinya 10 data per halaman.
Jika ingin 15:
paginate(15)
Untuk tutorial ini kita gunakan 10 agar daftar tetap nyaman dilihat.
50. Struktur View Setelah Tahap 04
Setelah selesai, struktur utama:
resources/views/
├── categories/
│ ├── create.blade.php
│ ├── edit.blade.php
│ └── index.blade.php
│
├── units/
│ ├── create.blade.php
│ ├── edit.blade.php
│ └── index.blade.php
│
├── customers/
│ ├── create.blade.php
│ ├── edit.blade.php
│ └── index.blade.php
│
└── suppliers/
├── create.blade.php
├── edit.blade.php
└── index.blade.php
51. Struktur Controller Setelah Tahap 04
app/Http/Controllers/
├── CategoryController.php
├── CustomerController.php
├── SupplierController.php
└── UnitController.php
52. Checklist Pengujian Category
Pastikan:
[ ] index kategori dapat dibuka
[ ] pencarian bekerja
[ ] tambah kategori bekerja
[ ] nama kosong ditolak
[ ] nama duplikat ditolak
[ ] edit kategori bekerja
[ ] hapus kategori kosong bekerja
[ ] kategori yang digunakan produk tidak dapat dihapus
53. Checklist Pengujian Unit
Pastikan:
[ ] index satuan dapat dibuka
[ ] pencarian nama bekerja
[ ] pencarian simbol bekerja
[ ] tambah satuan bekerja
[ ] nama duplikat ditolak
[ ] simbol wajib diisi
[ ] edit satuan bekerja
[ ] satuan yang digunakan produk tidak dapat dihapus
54. Checklist Pengujian Customer
Pastikan:
[ ] index pelanggan dapat dibuka
[ ] pencarian nama bekerja
[ ] pencarian telepon bekerja
[ ] pencarian alamat bekerja
[ ] tambah pelanggan bekerja
[ ] edit pelanggan bekerja
[ ] pelanggan tanpa transaksi dapat dihapus
[ ] pelanggan dengan transaksi tidak dapat dihapus
Pada tahap ini pelanggan kemungkinan belum mempunyai transaksi, sehingga pengujian proteksi transaksi akan lebih mudah dilakukan setelah fitur penjualan selesai.
55. Checklist Pengujian Supplier
Pastikan:
[ ] index supplier dapat dibuka
[ ] pencarian bekerja
[ ] tambah supplier bekerja
[ ] edit supplier bekerja
[ ] supplier tanpa transaksi dapat dihapus
[ ] supplier dengan transaksi tidak dapat dihapus
Proteksi supplier yang sudah mempunyai pembelian akan dapat diuji penuh setelah fitur pembelian selesai.
56. Kesalahan Umum: Route Tidak Ditemukan
Jika muncul:
Route [categories.index] not defined
jalankan:
php artisan route:list
Pastikan:
categories.index
tersedia.
Periksa import controller dan resource route.
57. Kesalahan Umum: View Tidak Ditemukan
Jika muncul:
View [categories.index] not found
pastikan file:
resources/views/categories/index.blade.php
tersedia.
Nama folder dan file harus sesuai.
58. Kesalahan Umum: 419 Page Expired
Biasanya terjadi karena form tidak mempunyai:
@csrf
Pastikan semua form POST, PUT, dan DELETE mempunyai token CSRF.
59. Kesalahan Umum: Method Not Allowed
Jika route update mengharapkan:
PUT
tetapi form hanya mengirim POST, pastikan ada:
@method('PUT')
Untuk delete:
@method('DELETE')
60. Kesalahan Umum: Pagination Tidak Bergaya Bootstrap
Pastikan:
Paginator::useBootstrapFive();
sudah ditambahkan pada:
AppServiceProvider
61. Kesalahan Umum: Undefined Variable search
Pastikan controller mengirim:
return view('categories.index', compact(
'categories',
'search'
));
Jika view menggunakan:
{{ $search }}
tetapi controller tidak mengirim variabel tersebut, akan muncul error.
62. Kesalahan Umum: Kasir Bisa Membuka Master Data
Pastikan route berada di dalam:
Route::middleware(['auth', 'role:admin'])->group(function () {
// ...
});
Jangan hanya menggunakan:
auth
jika memang master data hanya boleh dikelola admin.
63. Kesalahan Umum: Delete Menghasilkan Foreign Key Error
Jika data sudah terhubung ke record lain, controller harus melakukan pengecekan sebelum delete.
Contoh:
if ($supplier->purchases()->exists()) {
// tolak delete
}
Jika tetap terjadi error, periksa relasi model dan struktur foreign key migration.
64. Kesalahan Umum: Menghapus Data Hanya dengan Link GET
Jangan membuat delete seperti:
<a href="/categories/1/delete">Hapus</a>
Gunakan form:
POST + @method('DELETE')
agar sesuai dengan resource route dan lebih tepat secara HTTP.
65. Kesalahan Umum: Tidak Melakukan Validasi Server
Atribut:
required
di browser membantu pengguna, tetapi bukan pengganti validasi server.
Controller tetap harus menggunakan:
$request->validate([...]);
Pengguna dapat memanipulasi request sehingga validasi backend tetap wajib.
66. Pengembangan Selanjutnya
CRUD yang kita buat masih dapat dikembangkan dengan:
sorting
filter lanjutan
export Excel
soft delete
modal form
AJAX
DataTables
bulk action
Namun fitur tersebut tidak diperlukan agar sistem kasir dasar dapat berfungsi.
Untuk tutorial ini kita prioritaskan alur yang mudah dipahami pemula.
67. Mengapa Produk Dipisahkan dari CRUD Master Data?
Pada versi awal tutorial, ProductController digabung bersama kategori, satuan, pelanggan, dan supplier.
Namun produk mempunyai aturan yang lebih banyak:
SKU unik
kategori
satuan
harga beli
harga jual
stok
stok minimum
status aktif
relasi transaksi
pencarian
filter
proteksi delete
Produk juga berhubungan langsung dengan stok.
Karena itu produk dipisahkan menjadi tutorial berikutnya agar:
- artikel master data tidak terlalu panjang;
- alur belajar lebih jelas;
- aturan stok dapat dijelaskan dengan benar;
- pemula tidak mencampur CRUD biasa dengan manajemen stok.
URL crud-master-data tetap dipertahankan sehingga URL lama tidak berubah.
68. Checkpoint Hasil
Setelah Tahap 04 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 : tahap berikutnya
Transaksi penjualan : belum
Pembelian : belum
Manajemen stok : belum
Dashboard : belum
Laporan : belum
Nota : belum
Testing/deploy : belum
69. Ringkasan
Pada tahap keempat kita sudah membuat CRUD master data sistem kasir.
Master data yang selesai:
Kategori
Satuan
Pelanggan
Supplier
Setiap modul sudah mempunyai:
index
create
store
edit
update
destroy
Kita juga sudah menambahkan:
validasi server
CSRF protection
route model binding
pencarian
pagination
flash message
proteksi delete
middleware admin
Kategori dan satuan tidak dapat dihapus jika masih digunakan produk.
Pelanggan tidak dapat dihapus jika sudah mempunyai transaksi penjualan.
Supplier tidak dapat dihapus jika sudah mempunyai transaksi pembelian.
Tahap berikutnya akan membahas CRUD produk secara khusus karena produk merupakan data inti sistem kasir dan berhubungan langsung dengan kategori, satuan, harga, stok minimum, status aktif, dan transaksi.





Komentar
Tulis Komentar