Looking to hire Laravel developers? Try LaraJobs

laravel-inventory maintained by centrex

Description
Manage inventory accross warehouses
Author
Last update
2026/04/13 05:01 (dev-main)
License
Downloads
7

Comments
comments powered by Disqus

laravel-inventory

Latest Version on Packagist GitHub Tests Action Status GitHub Code Style Action Status Total Downloads

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

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.