Membangun Sistem Informasi Inventory Berbasis Web

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 klasi...

Membangun Sistem Informasi Inventory Berbasis Web

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

PendekatanKelebihanKekuranganKapan dipakai
Satu kolom stock diedit langsungSangat sederhana, baca stok cepatTanpa riwayat, selisih tidak bisa dilacakPrototipe saja
Buku besar mutasi (SUM dari mutasi)Setiap perubahan tercatat, mudah diauditPerlu agregasi saat membaca stokSebagian besar aplikasi kecil-menengah
Mutasi + kolom cache stokBisa diaudit dan baca stok cepatCache harus selalu diperbarui dalam transaksi yang samaProduk 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.

  1. Bekukan transaksi untuk lokasi yang dihitung, atau catat waktu mulai opname.
  2. Petugas mengisi jumlah fisik per produk.
  3. Sistem menghitung selisih: fisik dikurangi stok sistem.
  4. Selisih positif dicatat sebagai mutasi in, selisih negatif sebagai out, dengan reference seperti OPNAME-2026-10.
  5. 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 withSum atau query agregat.
  • Menghapus produk yang punya riwayat. Nonaktifkan, jangan hapus.

Checklist Sebelum Dipakai

  • Stok awal dimasukkan sebagai mutasi in dengan 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.

Ingin langsung mempraktikkannya?

Buat akun gratis untuk mengunduh contoh aplikasi Laravel, menyimpan tutorial favorit, dan mencoba kursus. Gratis, bisa daftar dengan akun Google.

Daftar Gratis Lihat katalog aplikasi
Yudhi
Ditulis oleh
Yudhi
Founder & Lead Developer, GudangCode

Yudhi adalah founder GudangCode dan developer Laravel dengan pengalaman membangun puluhan sistem informasi bisnis siap pakai — mulai dari POS, HRIS, hingga aplikasi manajemen. Ia menulis panduan dan artikel di GudangCode untuk membantu developer Indonesia menjalankan, memahami, dan men-deploy source code Laravel dengan benar.

Laravel PHP MySQL Sistem Informasi Bisnis
Lihat semua artikel Yudhi
Sistem Informasi Inventory Web Stok
Bacaan Paling Dicari
📚 Free Learning Hub

Learn Coding for Free at DhieCoderWeb

Explore Laravel, PHP, JavaScript tutorials, source code, web development guides, and practical programming tips.

DhieCoderWeb
100+
Tutorials
Free
Learning
SEO
Tips
Visit Dhiecoderweb.com →