centrex / laravel-inventory
Manage inventory accross warehouses
Requires
- php: ^8.2
- centrex/tallui: *
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/routing: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- illuminate/view: ^11.0|^12.0|^13.0
- livewire/livewire: ^4.0
Requires (Dev)
- larastan/larastan: ^2.0
- laravel/pint: ^1.0
- nunomaduro/collision: ^8.5
- orchestra/testbench: ^9.5
- pestphp/pest: ^3.4
- pestphp/pest-plugin-arch: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
- phpstan/extension-installer: ^1.1
- phpstan/phpstan-deprecation-rules: ^1.0
- phpstan/phpstan-phpunit: ^1.0
- rector/rector: ^1.2
- spatie/laravel-ray: ^1.26
README
Full multi-warehouse inventory management for Laravel. Supports weighted average costing (WAC), multi-currency purchasing and selling, inter-warehouse transfers with per-kg shipping costs, and five configurable sell price tiers per product per warehouse.
Contents
- Installation
- Configuration
- Core Concepts
- Exchange Rates
- Warehouses & Products
- Price Tiers & Pricing
- Purchase Orders & GRNs
- Sale Orders
- Inter-Warehouse Transfers
- Stock Adjustments
- Stock Queries & Reports
- Exceptions
- Environment Variables
- Testing
- Changelog
- License
Installation
composer require centrex/laravel-inventory
Publish the config and run migrations:
php artisan vendor:publish --tag="inventory-config"
php artisan migrate
Seed the default price tiers (base, wholesale, retail, dropshipping, fcom):
use Centrex\Inventory\Facades\Inventory; Inventory::seedPriceTiers();
Configuration
// config/inventory.php return [ 'base_currency' => env('INVENTORY_BASE_CURRENCY', 'BDT'), 'drivers' => ['database' => ['connection' => env('INVENTORY_DB_CONNECTION')]], 'table_prefix' => env('INVENTORY_TABLE_PREFIX', 'inv_'), 'wac_precision' => 4, 'exchange_rate_stale_days' => 1, 'price_not_found_throws' => true, 'qty_tolerance' => 0.0001, 'default_shipping_rate_per_kg' => 0, 'seed_price_tiers' => true, ];
Core Concepts
| Concept | Description |
|---|---|
| Base currency | BDT. Every financial amount is stored in BDT. Foreign-currency amounts are stored alongside with their exchange rate locked at the document level. |
| WAC | Weighted average cost per product per warehouse. Recalculated on every receipt and transfer receipt using a SELECT FOR UPDATE lock to prevent race conditions. |
| Price tiers | Five tiers: base, wholesale, retail, dropshipping, fcom. Prices can be set globally or overridden per warehouse. Warehouse-specific price wins. |
| Transfers | Moving stock between warehouses adds a per-kg shipping cost (allocated pro-rata across items by weight) to the landed cost at the destination, which feeds into the destination's WAC. |
| Stock movements | Append-only audit log. Every quantity change writes an immutable row. Voids write compensating rows — nothing is deleted or updated. |
Exchange Rates
Set exchange rates before creating any multi-currency documents.
use Centrex\Inventory\Facades\Inventory; // 1 CNY = 16.50 BDT on 2026-04-10 Inventory::setExchangeRate('CNY', 16.50, '2026-04-10'); // 1 USD = 110.00 BDT (defaults to today) Inventory::setExchangeRate('USD', 110.00); // Get the rate for a currency on a specific date $rate = Inventory::getExchangeRate('CNY', '2026-04-10'); // 16.50 // Convert between currencies $bdt = Inventory::convertToBdt(100, 'CNY'); // 1650.0000 BDT $usd = Inventory::convertFromBdt(1100, 'USD'); // 10.0000 USD
Warehouses & Products
use Centrex\Inventory\Models\{Warehouse, Product, ProductCategory}; // Create warehouses $wh_dhaka = Warehouse::create([ 'code' => 'WH-BD-01', 'name' => 'Dhaka Warehouse', 'country_code' => 'BD', 'currency' => 'BDT', // native purchase/sale currency 'is_default' => true, ]); $wh_china = Warehouse::create([ 'code' => 'WH-CN-01', 'name' => 'Guangzhou Warehouse', 'country_code' => 'CN', 'currency' => 'CNY', ]); $wh_us = Warehouse::create([ 'code' => 'WH-US-01', 'name' => 'New York Warehouse', 'country_code' => 'US', 'currency' => 'USD', ]); // Create a product with weight (required for transfer shipping cost calculation) $category = ProductCategory::create(['name' => 'Electronics', 'slug' => 'electronics']); $product = Product::create([ 'category_id' => $category->id, 'sku' => 'PHONE-X1', 'name' => 'Smartphone X1', 'unit' => 'pcs', 'weight_kg' => 0.350, // 350g per unit 'is_stockable' => true, ]);
Price Tiers & Pricing
Set prices
use Centrex\Inventory\Facades\Inventory; use Centrex\Inventory\Enums\PriceTierCode; // Global prices (apply to all warehouses unless overridden) Inventory::setPrice($product->id, PriceTierCode::BASE->value, 5500.00); Inventory::setPrice($product->id, PriceTierCode::WHOLESALE->value, 5200.00); Inventory::setPrice($product->id, PriceTierCode::RETAIL->value, 6500.00); Inventory::setPrice($product->id, PriceTierCode::DROPSHIPPING->value, 6200.00); Inventory::setPrice($product->id, PriceTierCode::FCOM->value, 6800.00); // Warehouse-specific override (China warehouse sells cheaper due to lower cost base) Inventory::setPrice($product->id, PriceTierCode::WHOLESALE->value, 4800.00, $wh_china->id); Inventory::setPrice($product->id, PriceTierCode::RETAIL->value, 5900.00, $wh_china->id); // With local currency reference (informational — BDT is the source of truth) Inventory::setPrice($product->id, PriceTierCode::RETAIL->value, 6500.00, $wh_dhaka->id, [ 'price_local' => 6500.00, 'currency' => 'BDT', ]); // Time-limited price Inventory::setPrice($product->id, PriceTierCode::RETAIL->value, 5500.00, null, [ 'effective_from' => '2026-06-01', 'effective_to' => '2026-06-30', ]);
Resolve & read prices
// Resolve the effective retail price at Dhaka warehouse // (warehouse-specific wins over global; falls back to global if no warehouse price) $price = Inventory::resolvePrice($product->id, 'retail', $wh_dhaka->id); echo $price->price_amount; // 6500.00 // Get the full price sheet for a product at a warehouse (all tiers) $sheet = Inventory::getPriceSheet($product->id, $wh_china->id); // Returns a Collection: // [ // ['tier_code' => 'base', 'price_amount' => 5500.00, 'source' => 'global'], // ['tier_code' => 'wholesale', 'price_amount' => 4800.00, 'source' => 'warehouse'], // ['tier_code' => 'retail', 'price_amount' => 5900.00, 'source' => 'warehouse'], // ['tier_code' => 'dropshipping', 'price_amount' => 6200.00, 'source' => 'global'], // ['tier_code' => 'fcom', 'price_amount' => 6800.00, 'source' => 'global'], // ]
Purchase Orders & GRNs
Create a purchase order
Each PO targets a single warehouse and a single supplier currency. The exchange rate is locked at creation time.
use Centrex\Inventory\Facades\Inventory; use Centrex\Inventory\Models\Supplier; $supplier = Supplier::create([ 'code' => 'SUP-CN-001', 'name' => 'Shenzhen Electronics Co.', 'currency' => 'CNY', ]); // Purchase in CNY for the China warehouse $po = Inventory::createPurchaseOrder([ 'warehouse_id' => $wh_china->id, 'supplier_id' => $supplier->id, 'currency' => 'CNY', 'exchange_rate' => 16.50, // optional: auto-fetched from exchange_rates if omitted 'tax_local' => 100.00, 'shipping_local' => 50.00, 'notes' => 'Spring restock order', 'items' => [ [ 'product_id' => $product->id, 'qty_ordered' => 200, 'unit_price_local' => 180.00, // CNY per unit = 2,970 BDT at 16.50 ], ], ]); // $po->po_number => "PO-20260410-0001" // $po->total_amount => (200 × 180 × 16.50) + tax_amount + shipping_amount // Move through statuses Inventory::submitPurchaseOrder($po->id); // draft → submitted Inventory::confirmPurchaseOrder($po->id); // submitted → confirmed
Receive stock (GRN)
// Create a draft GRN against the PO $grn = Inventory::createStockReceipt($po->id, [ [ 'purchase_order_item_id' => $po->items->first()->id, 'qty_received' => 200, // unit_cost_local defaults to PO unit_price_local if omitted ], ]); // Post the GRN: increments inv_warehouse_products.qty_on_hand, // recalculates WAC, writes a stock_movement row $grn = Inventory::postStockReceipt($grn->id); // Check new stock level $stock = Inventory::getStockLevel($product->id, $wh_china->id); echo $stock->qty_on_hand; // 200 echo $stock->wac_amount; // 2970.0000 (180 CNY × 16.50) // Void a posted GRN (writes compensating movement — never deletes) Inventory::voidStockReceipt($grn->id);
Sale Orders
Create and fulfill a sale order
use Centrex\Inventory\Facades\Inventory; use Centrex\Inventory\Models\Customer; $customer = Customer::create([ 'code' => 'CUST-001', 'name' => 'Acme Traders', 'currency' => 'USD', 'price_tier_id' => \Centrex\Inventory\Models\PriceTier::where('code', 'wholesale')->value('id'), ]); // Sell in USD from the Dhaka warehouse at wholesale prices $so = Inventory::createSaleOrder([ 'warehouse_id' => $wh_dhaka->id, 'customer_id' => $customer->id, 'price_tier_code' => 'wholesale', 'currency' => 'USD', 'exchange_rate' => 110.00, 'items' => [ [ 'product_id' => $product->id, 'qty_ordered' => 50, // unit_price_local auto-resolved from inv_product_prices (wholesale tier, Dhaka warehouse) // override with 'unit_price_local' => 47.27 if needed ], ], ]); // Confirm → reserve stock → fulfill Inventory::confirmSaleOrder($so->id); Inventory::reserveStock($so->id); // increments qty_reserved, blocks overselling Inventory::fulfillSaleOrder($so->id); // decrements qty_on_hand, stamps COGS at WAC $so->refresh(); echo $so->status->value; // "fulfilled" echo $so->cogs_amount; // 50 × wac_amount at time of fulfillment echo $so->grossMarginPct(); // gross margin % // Partial fulfillment — supply a qty per line item Inventory::fulfillSaleOrder($so->id, [ $so->items->first()->id => 30, // fulfill only 30 of 50 ordered ]); // $so->status → "partial" // Cancel (releases reserved qty automatically) Inventory::cancelSaleOrder($so->id);
Per-line price tier override
$so = Inventory::createSaleOrder([ 'warehouse_id' => $wh_dhaka->id, 'price_tier_code' => 'retail', // order-level default 'currency' => 'BDT', 'exchange_rate' => 1.0, 'items' => [ [ 'product_id' => $product->id, 'qty_ordered' => 10, 'price_tier_code' => 'fcom', // override tier for this line 'discount_pct' => 5, // 5% line discount ], ], ]);
Inter-Warehouse Transfers
Stock moved between warehouses carries a shipping cost per kg. This shipping cost is spread across items pro-rata by weight and added to the landed unit cost at the destination, which then feeds into the destination's WAC.
use Centrex\Inventory\Facades\Inventory; // Transfer 100 units from China → Dhaka at 15 BDT/kg shipping $transfer = Inventory::createTransfer([ 'from_warehouse_id' => $wh_china->id, 'to_warehouse_id' => $wh_dhaka->id, 'shipping_rate_per_kg' => 15.00, 'notes' => 'Monthly replenishment', 'items' => [ [ 'product_id' => $product->id, 'qty_sent' => 100, // weight_kg_total = 100 × 0.350 kg = 35 kg ], ], ]); // $transfer->shipping_cost_amount => 35 kg × 15 = 525.00 BDT // $transfer->items[0]->shipping_allocated_amount => 525.00 (100% weight share) // $transfer->items[0]->unit_landed_cost_amount => source_wac + 525/100 = WAC + 5.25 // Dispatch: decrements China stock, increments China qty_in_transit Inventory::dispatchTransfer($transfer->id); // Receive at Dhaka: increments Dhaka stock, recalculates Dhaka WAC // using unit_landed_cost_amount (source WAC + allocated shipping) Inventory::receiveTransfer($transfer->id); // Partial receipt — supply a qty per transfer item Inventory::receiveTransfer($transfer->id, [ $transfer->items->first()->id => 60, // receive 60 of 100 sent ]); // $transfer->status → "partial"
Multi-product transfer
$transfer = Inventory::createTransfer([ 'from_warehouse_id' => $wh_us->id, 'to_warehouse_id' => $wh_dhaka->id, 'shipping_rate_per_kg' => 80.00, // air freight 'items' => [ ['product_id' => $productA->id, 'qty_sent' => 20], // 2 kg each → 40 kg ['product_id' => $productB->id, 'qty_sent' => 50], // 0.1 kg each → 5 kg // total weight: 45 kg, shipping: 3,600 BDT // productA gets: 40/45 × 3600 = 3,200 BDT shipping // productB gets: 5/45 × 3600 = 400 BDT shipping ], ]);
Stock Adjustments
Use adjustments for cycle counts, write-offs, damage, theft, or expiry.
use Centrex\Inventory\Facades\Inventory; use Centrex\Inventory\Enums\AdjustmentReason; // The system reads current qty_on_hand automatically — you only supply qty_actual $adjustment = Inventory::createAdjustment([ 'warehouse_id' => $wh_dhaka->id, 'reason' => AdjustmentReason::CYCLE_COUNT->value, 'notes' => 'Monthly cycle count — bay 3', 'items' => [ [ 'product_id' => $product->id, 'qty_actual' => 145, // system shows 150, actual count is 145 → delta: -5 ], ], ]); // Post: applies qty_delta to warehouse stock, writes adjustment_out movement Inventory::postAdjustment($adjustment->id); // Write-off example $adj = Inventory::createAdjustment([ 'warehouse_id' => $wh_dhaka->id, 'reason' => AdjustmentReason::DAMAGE->value, 'items' => [ ['product_id' => $product->id, 'qty_actual' => 140], ], ]); Inventory::postAdjustment($adj->id);
Stock Queries & Reports
Stock levels
use Centrex\Inventory\Facades\Inventory; // Single product at a warehouse $stock = Inventory::getStockLevel($product->id, $wh_dhaka->id); echo $stock->qty_on_hand; // 145 echo $stock->qty_reserved; // 50 (reserved by pending sale orders) echo $stock->qty_in_transit; // 100 (dispatched transfer not yet received) echo $stock->qtyAvailable(); // qty_on_hand - qty_reserved = 95 echo $stock->wac_amount; // weighted average cost in BDT // All products at a warehouse $levels = Inventory::getStockLevels($wh_dhaka->id); // Low stock alerts (all warehouses, or filter by one) $lowStock = Inventory::getLowStockItems(); $lowStock = Inventory::getLowStockItems($wh_dhaka->id); // Total stock value in BDT $totalValue = Inventory::getStockValue(); // all warehouses $warehouseValue = Inventory::getStockValue($wh_dhaka->id); // single warehouse
Valuation report
$report = Inventory::stockValuationReport($wh_dhaka->id); // Returns a Collection of arrays: // [ // 'warehouse' => 'Dhaka Warehouse', // 'sku' => 'PHONE-X1', // 'product' => 'Smartphone X1', // 'qty_on_hand' => 145.0, // 'qty_reserved' => 50.0, // 'qty_available' => 95.0, // 'wac_amount' => 3105.2500, // 'total_value_amount' => 450261.25, // ]
Movement history
$movements = Inventory::getMovementHistory( productId: $product->id, warehouseId: $wh_dhaka->id, from: '2026-01-01', to: '2026-04-30', ); foreach ($movements as $m) { echo "[{$m->moved_at}] {$m->movement_type->label()} {$m->direction} {$m->qty} " . "| before: {$m->qty_before} → after: {$m->qty_after} " . "| WAC: {$m->wac_amount} BDT\n"; }
Exceptions
| Exception | Thrown when |
|---|---|
PriceNotFoundException |
No active price found for product + tier + warehouse (when price_not_found_throws = true) |
InsufficientStockException |
Sale or transfer dispatch would result in negative available stock |
InvalidTransitionException |
A status transition is not allowed (e.g. posting an already-posted GRN) |
use Centrex\Inventory\Exceptions\{InsufficientStockException, PriceNotFoundException}; try { Inventory::reserveStock($so->id); } catch (InsufficientStockException $e) { // Notify user: $e->getMessage() } try { $price = Inventory::resolvePrice($product->id, 'fcom', $wh_china->id); } catch (PriceNotFoundException $e) { // No fcom price configured for this product/warehouse }
Environment Variables
INVENTORY_BASE_CURRENCY=BDT INVENTORY_DB_CONNECTION=mysql # optional dedicated DB connection INVENTORY_TABLE_PREFIX=inv_ INVENTORY_WAC_PRECISION=4 INVENTORY_EXCHANGE_RATE_STALE_DAYS=1 INVENTORY_PRICE_NOT_FOUND_THROWS=true INVENTORY_QTY_TOLERANCE=0.0001 INVENTORY_DEFAULT_SHIPPING_RATE_KG=0 INVENTORY_SEED_PRICE_TIERS=true
Testing
composer lint # apply Pint formatting composer refacto # apply Rector refactors composer test:types # PHPStan static analysis composer test:unit # Pest unit tests composer test # full suite
Changelog
Please see CHANGELOG for recent changes.
Contributing
Please see CONTRIBUTING for details.
Credits
License
The MIT License (MIT). Please see License File for more information.