Skip to main content

Overview

ShelfWise uses a flexible product model that supports both simple products and complex multi-variant products. Each product can have multiple variants (e.g., sizes, colors), and each variant can be sold in different packaging types (e.g., loose items, boxes, cartons).

Product Structure

The product hierarchy in ShelfWise follows this structure:
Product (Blue Shirt)
└── Variant 1 (Size: Small)
    ├── Packaging: Loose (1 unit)
    └── Packaging: Pack of 5 (5 units)
└── Variant 2 (Size: Medium)
    ├── Packaging: Loose (1 unit)
    └── Packaging: Pack of 5 (5 units)

Product Model

Products are defined in app/Models/Product.php and include:
name
string
required
Product name displayed to customers and staff
slug
string
required
URL-friendly identifier, auto-generated from name
product_type_id
integer
required
Reference to ProductType (e.g., retail, pharmaceutical, food)
category_id
integer
Optional category for organization
has_variants
boolean
default:"false"
Whether this product has multiple variants
is_active
boolean
default:"true"
Controls visibility in POS and storefront. See state management below.
track_stock
boolean
default:"true"
Whether inventory tracking is enabled for this product
is_taxable
boolean
default:"true"
Whether tax should be applied to this product
Whether to highlight this product in storefront

Product State Management

Products use both is_active flag and soft deletes for different purposes:
Stateis_activedeleted_atBehavior
ActivetruenullFully visible: POS, Storefront, Admin, Reports
InactivefalsenullHidden from POS/Storefront, still visible in Admin/Reports
DeletedN/AtimestampCompletely hidden, can be restored

When to Use is_active = false

  • Temporarily disable sales (out of season, pending restock)
  • Hide from customer-facing channels without losing data
  • Maintain in reports and historical data
  • Quick toggle for product availability

When to Use Soft Delete

  • Permanently remove from all active operations
  • Discontinue product while preserving historical order data
  • Complete removal from POS, Storefront, and Admin views
Soft deleted products still appear in historical order data to maintain data integrity.

Product Variants

Variants represent specific variations of a product. Defined in app/Models/ProductVariant.php:
sku
string
required
Stock Keeping Unit - unique identifier for inventory tracking
barcode
string
Barcode for scanning at POS
name
string
Variant-specific name (e.g., “Size M”, “Blue”)
attributes
array
JSON attributes defining the variant (e.g., {"size": "M", "color": "Blue"})
price
decimal
required
Selling price per base unit
cost_price
decimal
Cost per base unit. Automatically updated using weighted average method.
reorder_level
integer
default:"0"
Minimum stock level before triggering reorder alert
base_unit_name
string
default:"Unit"
Name of the smallest sellable unit (e.g., “Piece”, “Tablet”, “ml”)
is_active
boolean
default:"true"
Whether this variant is available for sale
is_available_online
boolean
default:"false"
Whether this variant is visible in the online storefront

Variant State Management

Variants also use dual state management:
Set is_active = false when:
  • Temporarily suspend sales of this specific variant
  • Variant out of stock but may return (e.g., seasonal size)
  • Testing new variant before public release
  • Inventory still tracked and visible in admin

Stock Calculations

Variants provide computed attributes for stock levels:
// app/Models/ProductVariant.php:143-163

$variant->total_stock      // Sum of quantity across all locations
$variant->available_stock  // Total - reserved quantities
Use eager loading with inventoryLocations to avoid N+1 queries:
ProductVariant::with('inventoryLocations')->get();

Weighted Average Cost

ShelfWise automatically calculates cost price using the weighted average method when stock is purchased:
// app/Models/ProductVariant.php:168-189

public function updateWeightedAverageCost(int $newQuantity, float $newCostPerUnit): void
{
    \DB::transaction(function () use ($newQuantity, $newCostPerUnit) {
        $variant = self::lockForUpdate()->find($this->id);
        $currentQty = $variant->total_stock;
        $currentCost = (float)$variant->cost_price;
        
        $newAvg = (($currentQty * $currentCost) + ($newQuantity * $newCostPerUnit))
            / ($currentQty + $newQuantity);
        
        $variant->update(['cost_price' => round($newAvg, 2)]);
    });
}

Example Calculation

  1. You have 100 units at 10each(totalvalue:10 each (total value: 1,000)
  2. You purchase 50 more units at 12each(totalvalue:12 each (total value: 600)
  3. New weighted average: (1,000+1,000 + 600) / (100 + 50) = $10.67 per unit
The weighted average calculation uses pessimistic locking to prevent race conditions during concurrent purchases.

Creating Products

Products are created through the ProductService in app/Services/ProductService.php:
// app/Services/ProductService.php:22-97

use App\Services\ProductService;

$product = app(ProductService::class)->create(
    data: [
        'name' => 'Blue T-Shirt',
        'product_type_slug' => 'retail-clothing',
        'shop_id' => $shop->id,
        'category_id' => $category->id,
        'has_variants' => true,
        'is_active' => true,
        'variants' => [
            [
                'sku' => 'TSHIRT-BLUE-S',
                'name' => 'Small',
                'price' => 19.99,
                'cost_price' => 8.50,
                'reorder_level' => 10,
                'base_unit_name' => 'Piece',
                'attributes' => ['size' => 'S', 'color' => 'Blue'],
            ],
            [
                'sku' => 'TSHIRT-BLUE-M',
                'name' => 'Medium',
                'price' => 19.99,
                'cost_price' => 8.50,
                'reorder_level' => 15,
                'base_unit_name' => 'Piece',
                'attributes' => ['size' => 'M', 'color' => 'Blue'],
            ],
        ],
    ],
    tenant: $tenant,
    shop: $shop
);

Simple Products (No Variants)

For products without variants, omit the has_variants and variants fields:
$product = app(ProductService::class)->create(
    data: [
        'name' => 'Notebook A4',
        'product_type_slug' => 'stationery',
        'shop_id' => $shop->id,
        'sku' => 'NOTE-A4-001',
        'price' => 2.99,
        'cost_price' => 1.20,
        'reorder_level' => 50,
        'is_active' => true,
    ],
    tenant: $tenant,
    shop: $shop
);

Updating Products

Update products through the service layer:
// app/Services/ProductService.php:102-144

$product = app(ProductService::class)->update(
    product: $product,
    data: [
        'name' => 'Updated Product Name',
        'is_active' => false,
        'description' => 'New description',
    ]
);

Updating Variants

Variants can be updated individually:
// app/Services/ProductService.php:254-335

$variant = app(ProductService::class)->updateVariant(
    variant: $variant,
    data: [
        'price' => 24.99,
        'cost_price' => 10.00,
        'reorder_level' => 20,
        'is_active' => true,
    ]
);
When you update a variant’s price or cost_price, all associated packaging types are automatically updated proportionally based on their units_per_package ratio.

Multi-Tenant Isolation

CRITICAL: All product queries MUST filter by tenant_id
Always use the service layer or ensure tenant filtering:
// ✅ CORRECT - Service layer handles tenant isolation
$products = app(ProductService::class)->getAllProducts();

// ✅ CORRECT - Manual query with tenant filter
$products = Product::query()
    ->where('tenant_id', auth()->user()->tenant_id)
    ->get();

// ❌ WRONG - Missing tenant filter
$products = Product::all();
See app/Http/Controllers/ProductController.php:35 for implementation examples.

Query Scopes

Products and variants provide helpful query scopes:
// Get only active products
$products = Product::query()
    ->where('tenant_id', $tenantId)
    ->active()
    ->get();

// Get only active variants
$variants = ProductVariant::query()
    ->active()
    ->whereHas('product', fn($q) => $q->where('tenant_id', $tenantId))
    ->get();

Product Types

Products are categorized by type, which determines available features:
retail-general
ProductType
Standard retail products with simple pricing
pharmaceutical
ProductType
Requires batch tracking and expiry dates
food-beverage
ProductType
Requires expiry date tracking
electronics
ProductType
May require serial number tracking
Product types are defined in the database and can be customized per tenant.

Best Practices

  • Always eager load relationships to avoid N+1 queries
  • Use with(['inventoryLocations', 'product', 'packagingTypes']) when fetching variants
  • Cache product lists with Cache::tags(["tenant:{$tenantId}:products:list"])
  • InventoryLocation - Tracks stock quantities per location
  • ProductPackagingType - Defines different packaging units
  • StockMovement - Audit trail of all inventory changes
  • ProductCategory - Optional product categorization
  • ProductType - Defines product behavior and requirements

Build docs developers (and LLMs) love