Pemilik toko bertanya, "Stok kopi sachet tinggal berapa?" Sistem menjawab 40, rak menunjukkan 25, dan tidak ada yang bisa menjelaskan ke mana selisih 15 itu pergi. Ini gejala klasik aplikasi inventory yang menyimpan stok sebagai satu angka di tabel produk lalu menambah dan mengurangi angka itu langsung.
Cara itu terlihat sederhana, tetapi begitu ada retur, koreksi, atau audit, riwayatnya hilang. Tutorial ini membangun inventory yang bisa dipertanggungjawabkan di Laravel 11 (PHP 8.2+) memakai pola stock movement, yaitu buku besar mutasi stok. Cocok untuk toko, gudang, apotek, maupun bengkel.
Tiga Cara Menyimpan Stok
| Pendekatan | Kelebihan | Kekurangan | Kapan dipakai |
|---|---|---|---|
Satu kolom stock diedit langsung | Sangat sederhana, baca stok cepat | Tanpa riwayat, selisih tidak bisa dilacak | Prototipe saja |
| Buku besar mutasi (SUM dari mutasi) | Setiap perubahan tercatat, mudah diaudit | Perlu agregasi saat membaca stok | Sebagian besar aplikasi kecil-menengah |
| Mutasi + kolom cache stok | Bisa diaudit dan baca stok cepat | Cache harus selalu diperbarui dalam transaksi yang sama | Produk banyak, transaksi tinggi |
Kita mulai dari pendekatan kedua, lalu menambahkan cache di bagian akhir bila diperlukan.
1. Merancang Model Data
Tiga entitas inti: suppliers, products, dan stock_movements. Stok saat ini bukan kolom yang diedit manual, melainkan total barang masuk dikurangi total barang keluar.
php artisan make:model Supplier -m
php artisan make:model Product -m
php artisan make:model StockMovement -m
Schema::create('suppliers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('phone')->nullable();
$table->timestamps();
});
Schema::create('products', function (Blueprint $table) {
$table->id();
$table->string('sku')->unique();
$table->string('name');
$table->unsignedBigInteger('price')->default(0);
$table->unsignedInteger('min_stock')->default(5); // ambang stok menipis
$table->timestamps();
});
Schema::create('stock_movements', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->restrictOnDelete();
$table->foreignId('supplier_id')->nullable()->constrained();
$table->foreignId('user_id')->nullable()->constrained();
$table->enum('type', ['in', 'out']);
$table->unsignedInteger('qty');
$table->string('reference')->nullable(); // no. faktur, nota, atau kode opname
$table->text('note')->nullable();
$table->timestamps();
$table->index(['product_id', 'type']);
});
Beberapa keputusan desain di sini disengaja. Kolom type membedakan masuk dan keluar sementara qty selalu positif, sehingga tidak ada kebingungan tanda minus. Kolom reference dan user_id menjawab dua pertanyaan audit: dari dokumen mana mutasi ini berasal, dan siapa yang mencatatnya. Produk memakai restrictOnDelete agar produk yang punya riwayat mutasi tidak bisa dihapus begitu saja; nonaktifkan saja produknya. Jika Anda perlu mengubah struktur ini setelah aplikasi berjalan, ikuti praktik di migrasi database yang aman di Laravel.
2. Model dan Relasi
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Product extends Model
{
protected $fillable = ['sku', 'name', 'price', 'min_stock'];
public function movements(): HasMany
{
return $this->hasMany(StockMovement::class);
}
public function currentStock(): int
{
$in = (int) $this->movements()->where('type', 'in')->sum('qty');
$out = (int) $this->movements()->where('type', 'out')->sum('qty');
return $in - $out;
}
}
class StockMovement extends Model
{
protected $fillable = ['product_id', 'supplier_id', 'user_id', 'type', 'qty', 'reference', 'note'];
public function product()
{
return $this->belongsTo(Product::class);
}
}
Method currentStock() sengaja dibuat method biasa, bukan accessor. Accessor mudah terpanggil berulang kali tanpa disadari di loop Blade, dan setiap panggilan berarti query baru.
3. Mencatat Stok Masuk dan Keluar
Semua perubahan stok wajib lewat pencatatan mutasi. Untuk barang keluar, periksa dulu kecukupan stok. Bungkus dalam transaksi dan kunci baris produk agar dua kasir yang menjual barang terakhir bersamaan tidak membuat stok minus.
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
public function store(Request $request)
{
$data = $request->validate([
'product_id' => 'required|exists:products,id',
'type' => 'required|in:in,out',
'qty' => 'required|integer|min:1',
'reference' => 'nullable|string|max:100',
'note' => 'nullable|string|max:500',
]);
DB::transaction(function () use ($data, $request) {
$product = Product::lockForUpdate()->findOrFail($data['product_id']);
if ($data['type'] === 'out' && $product->currentStock() < $data['qty']) {
abort(422, 'Stok tidak mencukupi. Sisa: ' . $product->currentStock());
}
$product->movements()->create($data + ['user_id' => $request->user()?->id]);
});
return back()->with('success', 'Mutasi stok tercatat.');
}
Karena stok dihitung dari mutasi, koreksi menjadi jujur: salah input tidak dihapus, melainkan dibuat mutasi lawannya dengan catatan "koreksi nota X". Riwayat tetap utuh.
4. Alert Stok Menipis Tanpa N+1
Memanggil currentStock() untuk ratusan produk akan menjalankan ratusan query. Gunakan withSum agar total masuk dan keluar dihitung dalam satu query:
$products = Product::query()
->withSum(['movements as qty_in' => fn ($q) => $q->where('type', 'in')], 'qty')
->withSum(['movements as qty_out' => fn ($q) => $q->where('type', 'out')], 'qty')
->get()
->each(fn ($p) => $p->stock = (int) $p->qty_in - (int) $p->qty_out);
$lowStock = $products->filter(fn ($p) => $p->stock <= $p->min_stock);
Untuk ribuan produk, filter langsung di database supaya tidak semua baris dimuat ke memori:
$stockExpr = "COALESCE(SUM(CASE WHEN m.type = 'in' THEN m.qty ELSE 0 END), 0)
- COALESCE(SUM(CASE WHEN m.type = 'out' THEN m.qty ELSE 0 END), 0)";
$lowStock = Product::query()
->leftJoin('stock_movements as m', 'm.product_id', '=', 'products.id')
->select('products.id', 'products.sku', 'products.name', 'products.min_stock')
->selectRaw("$stockExpr AS stock")
->groupBy('products.id', 'products.sku', 'products.name', 'products.min_stock')
->havingRaw("$stockExpr <= products.min_stock")
->get();
Ekspresi stok ditulis ulang di havingRaw agar query berjalan di MySQL maupun PostgreSQL. Tips lain soal performa ada di optimasi performa query Eloquent Laravel.
5. Stok Opname
Stok opname adalah penghitungan fisik berkala. Hasilnya dicocokkan dengan stok sistem, lalu selisihnya dicatat sebagai mutasi penyesuaian, bukan dengan mengedit angka.
- Bekukan transaksi untuk lokasi yang dihitung, atau catat waktu mulai opname.
- Petugas mengisi jumlah fisik per produk.
- Sistem menghitung selisih: fisik dikurangi stok sistem.
- Selisih positif dicatat sebagai mutasi
in, selisih negatif sebagaiout, dengan reference sepertiOPNAME-2026-10. - Pemilik meninjau daftar selisih terbesar sebelum opname ditutup.
$diff = $physicalQty - $product->currentStock();
if ($diff !== 0) {
$product->movements()->create([
'type' => $diff > 0 ? 'in' : 'out',
'qty' => abs($diff),
'reference' => 'OPNAME-' . now()->format('Y-m'),
'note' => 'Penyesuaian stok opname',
'user_id' => auth()->id(),
]);
}
6. Laporan Mutasi per Periode
$report = StockMovement::query()
->whereBetween('created_at', [$start, $end])
->selectRaw('type, SUM(qty) as total')
->groupBy('type')
->pluck('total', 'type');
// $report['in'] = total masuk, $report['out'] = total keluar
Tambahkan groupBy('product_id') untuk laporan per produk, atau filter reference berawalan OPNAME- untuk melihat total selisih opname.
7. Kapan Perlu Kolom Cache Stok
Jika halaman kasir harus menampilkan stok ribuan produk secepat mungkin, tambahkan kolom stock di tabel products sebagai cache. Aturannya tegas: kolom ini hanya diubah di transaksi yang sama dengan pembuatan mutasi, misalnya dengan $product->increment('stock', $qty) atau decrement. Buat juga command terjadwal yang membandingkan cache dengan SUM mutasi setiap malam dan melaporkan bila ada beda. Tabel mutasi tetap menjadi sumber kebenaran.
8. Uji Logika Stok dengan Feature Test
Bagian yang paling sering rusak saat aplikasi dikembangkan adalah aturan stok, bukan tampilan. Beberapa feature test sederhana dengan Pest atau PHPUnit sudah cukup sebagai jaring pengaman: mencatat barang keluar melebihi stok harus menghasilkan status 422, mutasi masuk 10 lalu keluar 4 harus menyisakan 6, dan penyesuaian opname dengan selisih nol tidak boleh membuat mutasi baru. Jalankan test ini setiap kali Anda menambah fitur seperti retur atau transfer gudang.
Kesalahan Umum
- Mengedit kolom stok langsung. Riwayat hilang. Semua perubahan lewat mutasi.
- Tanpa transaksi dan lock. Penjualan bersamaan bisa membuat stok minus.
- Menghapus mutasi yang salah. Buat mutasi koreksi agar jejak audit tetap ada.
- Menghitung stok di loop Blade. Pakai
withSumatau query agregat. - Menghapus produk yang punya riwayat. Nonaktifkan, jangan hapus.
Checklist Sebelum Dipakai
- Stok awal dimasukkan sebagai mutasi
indengan reference "SALDO-AWAL". - Barang keluar melebihi stok sudah diuji dan ditolak.
- Setiap mutasi menyimpan pengguna dan nomor dokumen.
- Prosedur stok opname bulanan tertulis.
- Hak hapus produk dan mutasi hanya untuk admin, atau dimatikan sama sekali.
Dengan pola ini, fitur lanjutan seperti retur pembelian, transfer antar gudang (tambahkan warehouse_id), dan integrasi dengan kasir bisa dibangun tanpa merombak struktur. Untuk konteks bagaimana inventory terhubung ke penjualan, baca apa itu aplikasi kasir dan cara kerjanya.