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
Stockable Products
Non-Stockable Products
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
Services or products without inventory: Product :: create ([
'name' => 'Technical Support - 1 Hour' ,
'sku' => 'SRV-TECH-01' ,
'category_id' => 5 , // Services
'unit_id' => 3 , // Hour
'price' => 75.00 ,
'cost' => 0 ,
'is_stockable' => false ,
'is_active' => true ,
]);
Use Cases:
Professional services
Digital products
Consultation fees
Warranty extensions
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
Pricing
Sale Price
The price charged to customers:
Cost
Your acquisition or production cost:
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
Define Basic Info
$product = new Product ();
$product -> name = 'Wireless Mouse' ;
$product -> sku = 'ACC-MOUSE-001' ;
$product -> description = 'Ergonomic wireless mouse with USB receiver' ;
Set Category & Unit
$product -> category_id = 3 ; // Accessories
$product -> unit_id = 1 ; // Unit
Configure Pricing
$product -> cost = 12.50 ;
$product -> price = 24.99 ;
Set Inventory Options
$product -> is_stockable = true ;
$product -> is_active = true ;
$product -> save ();
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'
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.
Use Stockable Flag Correctly
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 ]);
Related Documentation
Inventory Module Stock tracking integration
Sales Module Selling products
Product Model API Model reference
Catalog Services Product catalog implementation