Skip to main content

Overview

The Products module manages your product catalog with support for categories, units of measure, pricing, cost tracking, and inventory control. It integrates with Sales and Inventory modules for complete product lifecycle management.

Categories

Organize products hierarchically

Pricing

Sale price and cost tracking

Inventory

Stock tracking integration

Product Structure

Core Fields

app/Models/Products/Product.php
protected $fillable = [
    'category_id',
    'unit_id',
    'name',
    'slug',
    'sku',               // Stock Keeping Unit
    'description',
    'image_path',
    'price',             // Sale price
    'cost',              // Purchase/production cost
    'is_active',
    'is_stockable',      // Track inventory?
];

Product Types

Physical products with inventory tracking:
Product::create([
    'name' => 'Laptop Dell Inspiron 15',
    'sku' => 'LAP-DELL-001',
    'category_id' => 1,  // Electronics
    'unit_id' => 1,      // Unit (piece)
    'price' => 899.99,
    'cost' => 650.00,
    'is_stockable' => true,
    'is_active' => true,
]);
Features:
  • Stock levels tracked in inventory
  • Low stock alerts
  • COGS calculated automatically
  • Multi-warehouse support

Categories

Organize products into logical groups:
app/Models/Products/Category.php
protected $fillable = [
    'name',
    'description',
];

public function products(): HasMany {
    return $this->hasMany(Product::class);
}
Example Structure:
📁 Electronics
   ├─ Laptops
   ├─ Desktops
   └─ Accessories

📁 Office Supplies
   ├─ Paper
   ├─ Writing Instruments
   └─ Organizers

📁 Services
   ├─ Technical Support
   └─ Installation

Units of Measure

Define how products are sold:
app/Models/Products/Unit.php
protected $fillable = [
    'name',          // e.g., "Unit", "Box", "Liter"
    'abbreviation',  // e.g., "un", "box", "L"
];
Common Units:

Piece

Individual units (un, pc, ea)

Weight

Kilograms, pounds, grams

Volume

Liters, gallons, ml

Length

Meters, feet, inches

Package

Box, case, pallet

Time

Hour, day, month

Pricing

Sale Price

The price charged to customers:
$product->price = 99.99;

Cost

Your acquisition or production cost:
$product->cost = 65.00;

Margin Calculation

$margin = $product->price - $product->cost;
$marginPercent = ($margin / $product->price) * 100;

echo "Margin: $" . number_format($margin, 2);
echo " (" . number_format($marginPercent, 1) . "%)";
Output: Margin: $34.99 (35.0%)

Formatted Price Display

app/Models/Products/Product.php
public function getFormattedPriceAttribute(): string
{
    $config = general_config();
    $symbol = $config->currency_symbol ?? '$';
    
    return $symbol . ' ' . number_format($this->price, 2);
}
Usage:
echo $product->formatted_price;
// Output: "$ 99.99"

Stock Integration

Total Stock Accessor

app/Models/Products/Product.php
public function getTotalStockAttribute()
{
    return $this->stocks()->sum('quantity');
}
Usage:
$product = Product::find(1);
echo "Available: {$product->total_stock} units";

Stock by Warehouse

$product = Product::with('stocks.warehouse')->find(1);

foreach ($product->stocks as $stock) {
    echo "{$stock->warehouse->name}: {$stock->quantity}\n";
}
Output:
Main Warehouse: 150
Store 1: 25
Store 2: 30

Low Stock Detection

$lowStock = Product::whereHas('stocks', function($q) {
        $q->whereColumn('quantity', '<=', 'min_stock');
    })
    ->with('stocks')
    ->get();

Scopes

Active Products

app/Models/Products/Product.php
public function scopeActivo(Builder $query): Builder
{
    return $query->where('is_active', true);
}

public function scopeInactivo(Builder $query): Builder
{
    return $query->where('is_active', false);
}
Usage:
$activeProducts = Product::activo()->get();
$inactiveProducts = Product::inactivo()->get();

Stockable Products

public function scopeStockable(Builder $query): Builder
{
    return $query->where('is_stockable', true);
}
Usage:
// Products that need inventory tracking
$inventoryProducts = Product::stockable()->get();

Optimized Loading

public function scopeWithIndexRelations(Builder $query): void
{
    $query->with([
        'category:id,name',
        'unit:id,name,abbreviation'
    ]);
}
Usage:
$products = Product::withIndexRelations()
    ->activo()
    ->paginate(50);

Product Queries

Search Products

$search = 'laptop';

$results = Product::where('name', 'like', "%{$search}%")
    ->orWhere('sku', 'like', "%{$search}%")
    ->orWhere('description', 'like', "%{$search}%")
    ->activo()
    ->get();

Products by Category

$category = Category::find(1);

$products = $category->products()
    ->activo()
    ->orderBy('name')
    ->get();

Best Selling Products

$bestSellers = Product::select('products.*')
    ->join('sale_items', 'sale_items.product_id', '=', 'products.id')
    ->join('sales', 'sales.id', '=', 'sale_items.sale_id')
    ->where('sales.status', Sale::STATUS_COMPLETED)
    ->selectRaw('SUM(sale_items.quantity) as total_sold')
    ->groupBy('products.id')
    ->orderByDesc('total_sold')
    ->take(10)
    ->get();

Profitability Analysis

$profitability = Product::select('products.*')
    ->join('sale_items', 'sale_items.product_id', '=', 'products.id')
    ->selectRaw('
        SUM(sale_items.quantity * sale_items.unit_price) as revenue,
        SUM(sale_items.quantity * products.cost) as cost,
        SUM(sale_items.quantity * (sale_items.unit_price - products.cost)) as profit
    ')
    ->groupBy('products.id')
    ->orderByDesc('profit')
    ->get();

Product Management

Creating Products

1

Define Basic Info

$product = new Product();
$product->name = 'Wireless Mouse';
$product->sku = 'ACC-MOUSE-001';
$product->description = 'Ergonomic wireless mouse with USB receiver';
2

Set Category & Unit

$product->category_id = 3;  // Accessories
$product->unit_id = 1;      // Unit
3

Configure Pricing

$product->cost = 12.50;
$product->price = 24.99;
4

Set Inventory Options

$product->is_stockable = true;
$product->is_active = true;
$product->save();
5

Initialize Stock (if stockable)

if ($product->is_stockable) {
    InventoryStock::create([
        'product_id' => $product->id,
        'warehouse_id' => 1,
        'quantity' => 100,
        'min_stock' => 10,
    ]);
}

Updating Products

$product = Product::find(1);

// Update price
$product->price = 27.99;

// Deactivate
$product->is_active = false;

$product->save();
Changing a product’s cost does not retroactively update historical sales. Only future sales will use the new cost.

Bulk Operations

// Activate multiple products
Product::whereIn('id', [1, 2, 3, 4, 5])
    ->update(['is_active' => true]);

// Price increase by category
Product::where('category_id', 2)
    ->increment('price', 5.00);

// Percentage increase
Product::where('category_id', 3)
    ->update(['price' => DB::raw('price * 1.10')]);

Product Images

Store product images for display:
// Upload and store
$path = $request->file('image')->store('products', 'public');

$product->image_path = $path;
$product->save();
Display:
@if($product->image_path)
    <img src="{{ asset('storage/' . $product->image_path) }}" 
         alt="{{ $product->name }}">
@else
    <img src="{{ asset('images/no-product.png') }}" 
         alt="No image">
@endif

SKU Management

Stock Keeping Units (SKU) uniquely identify products: Best Practices:
LAP-DELL-001  (Laptop, Dell, sequence)
ACC-MOUSE-WL  (Accessory, Mouse, Wireless)
SRV-TECH-01   (Service, Technical, sequence)
// Validation rule
'sku' => 'required|unique:products,sku'
Choose a format and stick to it:
  • CAT-DESC-###
  • ###-CAT-DESC
  • CATDESC###
For product variants:
SHIRT-RED-S   (Red shirt, size S)
SHIRT-RED-M   (Red shirt, size M)
SHIRT-BLU-S   (Blue shirt, size S)

Reporting

Product List

$products = Product::withIndexRelations()
    ->activo()
    ->orderBy('name')
    ->get();

Stock Value Report

$stockValue = Product::join('inventory_stocks', 'inventory_stocks.product_id', '=', 'products.id')
    ->selectRaw('
        products.*,
        SUM(inventory_stocks.quantity) as total_quantity,
        SUM(inventory_stocks.quantity * products.cost) as total_value
    ')
    ->groupBy('products.id')
    ->get();

Sales by Product

$salesReport = Product::join('sale_items', 'sale_items.product_id', '=', 'products.id')
    ->join('sales', 'sales.id', '=', 'sale_items.sale_id')
    ->where('sales.status', Sale::STATUS_COMPLETED)
    ->whereBetween('sales.sale_date', [$startDate, $endDate])
    ->selectRaw('
        products.name,
        SUM(sale_items.quantity) as units_sold,
        SUM(sale_items.subtotal) as revenue
    ')
    ->groupBy('products.id')
    ->orderByDesc('revenue')
    ->get();

Best Practices

Periodically review costs and prices:
// Products with low margins
$lowMargin = Product::whereRaw('(price - cost) / price < 0.20')
    ->get();
Deactivate discontinued products instead of deleting:
$product->is_active = false;
$product->save();
This preserves historical sales data.
Services should be non-stockable:
if ($product->is_stockable && $product->category->name === 'Services') {
    // Flag potential configuration error
}
For stockable products, configure low stock alerts:
InventoryStock::where('product_id', $productId)
    ->update(['min_stock' => 10]);

Inventory Module

Stock tracking integration

Sales Module

Selling products

Product Model API

Model reference

Catalog Services

Product catalog implementation

Build docs developers (and LLMs) love