Sistem informasi inventory adalah salah satu aplikasi yang paling banyak diminta klien: toko, gudang, apotek, hingga bengkel semua butuh tahu berapa stok tersisa saat ini. Kesalahan paling umum pemula adalah menyimpan stok sebagai satu angka di tabel produk lalu menambah/mengurangi angka itu langsung. Cara itu tampak sederhana, tetapi begitu ada retur, koreksi, atau audit, Anda kehilangan riwayat dan sulit menjelaskan kenapa stok tiba-tiba salah. Tutorial ini membangun inventory yang benar di Laravel 11 menggunakan pola stock movement (buku besar stok).
1. Merancang Model Data
Kita butuh tiga entitas inti: suppliers, products, dan stock_movements. Kunci desainnya: stok saat ini bukan kolom yang diedit manual, melainkan hasil penjumlahan dari seluruh mutasi. Setiap barang masuk dicatat sebagai baris in dan setiap barang keluar sebagai baris out.
php artisan make:model Supplier -m
php artisan make:model Product -m
php artisan make:model StockMovement -m
Migration suppliers dan products
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->integer('price')->default(0);
$table->integer('min_stock')->default(5); // ambang stok menipis
$table->timestamps();
});
Migration stock_movements
Inilah jantung sistem. Kolom type membedakan masuk/keluar, qty selalu positif, dan reference menyimpan nomor faktur atau nota agar setiap mutasi bisa dilacak asalnya.
Schema::create('stock_movements', function (Blueprint $table) {
$table->id();
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
$table->foreignId('supplier_id')->nullable()->constrained();
$table->enum('type', ['in', 'out']);
$table->unsignedInteger('qty');
$table->string('reference')->nullable();
$table->text('note')->nullable();
$table->timestamps();
});
php artisan migrate
2. Menyiapkan Model dan Relasi
Pada model Product, kita definisikan relasi ke mutasi dan sebuah accessor untuk menghitung stok berjalan. Accessor membuat $product->current_stock selalu akurat tanpa menyimpan angka yang bisa basi.
class Product extends Model
{
protected $fillable = ['sku', 'name', 'price', 'min_stock'];
public function movements()
{
return $this->hasMany(StockMovement::class);
}
public function getCurrentStockAttribute(): int
{
return (int) $this->movements()
->selectRaw("SUM(CASE WHEN type = 'in' THEN qty ELSE -qty END) as total")
->value('total');
}
public function getIsLowAttribute(): bool
{
return $this->current_stock <= $this->min_stock;
}
}
Model StockMovement cukup ringkas:
class StockMovement extends Model
{
protected $fillable = ['product_id', 'supplier_id', 'type', 'qty', 'reference', 'note'];
public function product()
{
return $this->belongsTo(Product::class);
}
}
3. Mencatat Stok Masuk dan Keluar
Semua perubahan stok harus lewat pencatatan mutasi. Untuk barang keluar, kita wajib memeriksa dulu apakah stok mencukupi supaya stok tidak jadi minus. Bungkus operasi dalam transaksi database agar aman dari kondisi balapan (race condition).
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',
]);
DB::transaction(function () use ($data) {
$product = Product::lockForUpdate()->findOrFail($data['product_id']);
if ($data['type'] === 'out' && $product->current_stock < $data['qty']) {
abort(422, 'Stok tidak mencukupi. Sisa: ' . $product->current_stock);
}
$product->movements()->create($data);
});
return back()->with('success', 'Mutasi stok tercatat.');
}
Karena stok dihitung dari mutasi, koreksi menjadi mudah: salah input tinggal dibuat mutasi lawannya, dan riwayat tetap utuh untuk audit.
4. Alert Stok Menipis
Pemilik toko ingin tahu barang apa yang harus segera dipesan ulang. Karena stok dihitung dari SUM mutasi, kita filter di level query agar cepat meski produk ribuan:
$lowStock = Product::query()
->withSum(['movements as stock' => function ($q) {
$q->selectRaw("SUM(CASE WHEN type = 'in' THEN qty ELSE -qty END)");
}], 'qty')
->get()
->filter(fn ($p) => $p->current_stock <= $p->min_stock);
Untuk dataset besar, lebih efisien menghitung agregat langsung di database dengan selectRaw dan havingRaw:
$lowStock = Product::query()
->leftJoin('stock_movements', 'stock_movements.product_id', '=', 'products.id')
->selectRaw('products.*, COALESCE(SUM(CASE WHEN type = "in" THEN qty ELSE -qty END), 0) as stock')
->groupBy('products.id')
->havingRaw('stock <= products.min_stock')
->get();
5. Laporan Sederhana
Laporan mutasi per periode cukup memfilter tabel stock_movements berdasarkan tanggal, lalu meringkas total masuk dan keluar:
$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
Kesalahan Umum yang Perlu Dihindari
- Menyimpan stok sebagai satu kolom yang diedit langsung: Anda kehilangan riwayat dan rawan stok jadi salah tanpa jejak. Pakai buku besar mutasi.
- Tidak memakai transaksi + lockForUpdate: dua permintaan bersamaan bisa membuat stok jadi minus.
- Menyimpan qty negatif untuk barang keluar: lebih rapi memakai kolom
typedan qty selalu positif.
Dengan pola stock movement ini, aplikasi inventory Anda akurat, mudah diaudit, dan siap dikembangkan menjadi fitur retur, transfer antar-gudang, atau stok opname tanpa merombak struktur data.