17 KiB
Product Relations
Overview
The Product Relations system enables complex relationships between products through a flexible, type-based association model. Products can be related to each other in various ways for different business purposes like upselling, bundling, cross-selling, and structural groupings.
Architecture
Database Structure
Product relations are stored in a many-to-many pivot table with type differentiation:
products
├── id
├── name
├── type
└── ...
product_relations (pivot table)
├── product_id → The source product
├── related_product_id → The target product
├── type → ProductRelationType enum
├── sort_order → Optional ordering
└── timestamps
Key Concepts
- Directional Relations: Relations go from
product_idtorelated_product_id - Typed Relations: Each relation has a specific type (e.g., UPSELL, RELATED)
- Flexible: Same product can have multiple relation types to different products
- Bidirectional Support: Some relations (POOL/SINGLE) create reverse relations automatically
Relation Types
Marketing Relations
These relations help with product discovery and sales optimization:
1. RELATED (ProductRelationType::RELATED)
Purpose: Products that are commonly viewed or purchased together
Use Case: "Customers also viewed" or "Similar products"
Example:
// Canon Camera → Canon Lenses (related products)
$camera->productRelations()->attach($lens->id, [
'type' => ProductRelationType::RELATED->value
]);
// Access
$relatedProducts = $camera->relatedProducts;
Direction: Can be one-way or bidirectional Visibility: Typically shown on product detail pages
2. UPSELL (ProductRelationType::UPSELL)
Purpose: Higher-tier or premium alternatives to encourage upgrades
Use Case: Suggesting a better/more expensive product
Example:
// Basic Plan → Premium Plan (upsell)
$basicPlan->productRelations()->attach($premiumPlan->id, [
'type' => ProductRelationType::UPSELL->value
]);
// Access
$upsells = $basicPlan->upsellProducts;
Direction: One-way (Basic → Premium, not reverse) Visibility: Product pages, cart pages, checkout
3. CROSS_SELL (ProductRelationType::CROSS_SELL)
Purpose: Complementary products often purchased together
Use Case: "Frequently bought together" or "Complete your purchase"
Example:
// Laptop → Mouse, Laptop Bag, USB Hub (cross-sells)
$laptop->productRelations()->attach([$mouse->id, $bag->id, $hub->id], [
'type' => ProductRelationType::CROSS_SELL->value
]);
// Access
$crossSells = $laptop->crossSellProducts;
Direction: One-way (main product → accessories) Visibility: Cart page, checkout, product page
4. DOWNSELL (ProductRelationType::DOWNSELL)
Purpose: Lower-priced alternatives if customer balks at price
Use Case: "Too expensive? Try this instead"
Example:
// Premium Plan → Basic Plan (downsell)
$premiumPlan->productRelations()->attach($basicPlan->id, [
'type' => ProductRelationType::DOWNSELL->value
]);
// Access
$downsells = $premiumPlan->downsellProducts;
Direction: One-way (Premium → Basic, not reverse) Visibility: Shown when customer hesitates or abandons cart
5. ADD_ON (ProductRelationType::ADD_ON)
Purpose: Optional extras that enhance the main product
Use Case: Extended warranties, gift wrapping, rush delivery
Example:
// Product → Extended Warranty (add-on)
$product->productRelations()->attach($warranty->id, [
'type' => ProductRelationType::ADD_ON->value
]);
// Access
$addOns = $product->addOnProducts;
Direction: One-way (main product → add-on) Visibility: Product page, during add-to-cart flow
Structural Relations
These relations define product hierarchy and composition:
6. VARIATION (ProductRelationType::VARIATION)
Purpose: Different versions/variants of the same base product
Use Case: Size, color, or configuration variations
Example:
// T-Shirt (Variable) → T-Shirt Small, T-Shirt Medium (variations)
$tshirt->productRelations()->attach([$small->id, $medium->id, $large->id], [
'type' => ProductRelationType::VARIATION->value
]);
// Access
$variations = $tshirt->variantProducts;
Direction: One-way (parent → variations) Visibility: Product page variant selector
7. BUNDLE (ProductRelationType::BUNDLE)
Purpose: Group of products sold together as a package
Use Case: "Starter Kit" or "Complete Package" offerings
Example:
// Starter Bundle → Individual Products
$starterKit->productRelations()->attach([
$product1->id,
$product2->id,
$product3->id
], [
'type' => ProductRelationType::BUNDLE->value
]);
// Access
$bundleProducts = $starterKit->bundleProducts;
Direction: One-way (bundle → components) Visibility: Bundle product page showing contents
Pool Relations (Special)
These are bidirectional relations for pool/single item management:
8. SINGLE (ProductRelationType::SINGLE)
Purpose: Link pool product to its individual component items
Use Case: Pool product pointing to actual bookable items
Example:
// Parking Pool → Individual Spots (single items)
$pool->productRelations()->attach($spot1->id, [
'type' => ProductRelationType::SINGLE->value
]);
// Access
$singleItems = $pool->singleProducts;
Direction: Pool → Single Items Auto-creates: Reverse POOL relation
9. POOL (ProductRelationType::POOL)
Purpose: Link individual items back to their pool container
Use Case: Single item referencing its parent pool
Example:
// This is automatically created when using attachSingleItems()
// Individual Spot → Parking Pool (pool reference)
// Access
$pools = $spot1->poolProducts;
Direction: Single Item → Pool
Auto-created: By attachSingleItems() method
Usage Examples
Basic Relations
// Create a product with related products
$camera = Product::find(1);
// Add related products (one at a time)
$camera->productRelations()->attach($lens->id, [
'type' => ProductRelationType::RELATED->value,
'sort_order' => 1,
]);
// Add multiple related products
$camera->productRelations()->attach([
$lens->id => ['type' => ProductRelationType::RELATED->value],
$tripod->id => ['type' => ProductRelationType::RELATED->value],
$bag->id => ['type' => ProductRelationType::RELATED->value],
]);
// Retrieve related products
$relatedProducts = $camera->relatedProducts;
Cross-Selling
// Set up cross-sells for laptop
$laptop->productRelations()->attach([
$mouse->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 1],
$bag->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 2],
$warranty->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 3],
]);
// In cart or checkout
$crossSells = $laptop->crossSellProducts()->orderBy('sort_order')->get();
Upselling Flow
// Basic → Premium upsell path
$basicPlan->productRelations()->attach($premiumPlan->id, [
'type' => ProductRelationType::UPSELL->value
]);
// Premium → Enterprise upsell path
$premiumPlan->productRelations()->attach($enterprisePlan->id, [
'type' => ProductRelationType::UPSELL->value
]);
// Show upsells
if ($cart->contains($basicPlan)) {
$suggestedUpgrade = $basicPlan->upsellProducts->first();
}
Product Variations
// Variable product with variations
$tshirt = Product::create([
'name' => 'T-Shirt',
'type' => ProductType::VARIABLE,
]);
// Create variations
$small = Product::create(['name' => 'T-Shirt Small', 'type' => ProductType::VARIATION]);
$medium = Product::create(['name' => 'T-Shirt Medium', 'type' => ProductType::VARIATION]);
$large = Product::create(['name' => 'T-Shirt Large', 'type' => ProductType::VARIATION]);
// Link variations
$tshirt->productRelations()->attach([
$small->id => ['type' => ProductRelationType::VARIATION->value],
$medium->id => ['type' => ProductRelationType::VARIATION->value],
$large->id => ['type' => ProductRelationType::VARIATION->value],
]);
// Display on product page
$variations = $tshirt->variantProducts;
Pool/Single Relations (Special Case)
Pool relations are unique because they're bidirectional:
// ✅ CORRECT WAY - Use attachSingleItems()
$pool = Product::create(['type' => ProductType::POOL]);
$spot1 = Product::create(['type' => ProductType::BOOKING]);
$spot2 = Product::create(['type' => ProductType::BOOKING]);
// This creates BOTH directions automatically:
$pool->attachSingleItems([$spot1->id, $spot2->id]);
// Now both directions work:
$pool->singleProducts; // Returns: [spot1, spot2]
$spot1->poolProducts; // Returns: [pool]
// ❌ WRONG WAY - Don't use attach() directly
$pool->productRelations()->attach($spot1->id, [
'type' => ProductRelationType::SINGLE->value
]);
// This only creates one direction! Missing reverse POOL relation.
What attachSingleItems() does:
public function attachSingleItems(array $singleItemIds): void
{
// 1. Attach SINGLE relations from pool to items
$this->productRelations()->attach(
array_fill_keys($singleItemIds, ['type' => ProductRelationType::SINGLE->value])
);
// 2. Attach reverse POOL relations from items to pool
foreach ($singleItemIds as $singleItemId) {
$singleItem = static::find($singleItemId);
$singleItem->productRelations()->attach($this->id, [
'type' => ProductRelationType::POOL->value
]);
}
}
Advanced Usage
Filtering by Relation Type
// Get all relations of a specific type
$upsells = $product->relationsByType(ProductRelationType::UPSELL);
// Or use dedicated method
$upsells = $product->upsellProducts;
// Get multiple types
$suggestions = $product->productRelations()
->whereIn('type', [
ProductRelationType::UPSELL->value,
ProductRelationType::CROSS_SELL->value
])
->get();
Custom Queries
// Relations with additional constraints
$premiumUpsells = $product->upsellProducts()
->where('products.price', '>', 10000)
->orderBy('products.price', 'asc')
->get();
// Limited cross-sells
$topCrossSells = $product->crossSellProducts()
->orderByPivot('sort_order')
->limit(3)
->get();
Checking Relations
// Check if product has any upsells
if ($product->upsellProducts()->exists()) {
// Show upsell section
}
// Count related products
$relatedCount = $product->relatedProducts()->count();
// Check specific relation
$hasRelation = $product->productRelations()
->where('related_product_id', $otherProduct->id)
->where('type', ProductRelationType::RELATED->value)
->exists();
Managing Relations
// Add relation
$product->productRelations()->attach($relatedId, [
'type' => ProductRelationType::RELATED->value,
'sort_order' => 1,
]);
// Update relation
$product->productRelations()->updateExistingPivot($relatedId, [
'sort_order' => 2
]);
// Remove relation
$product->productRelations()->detach($relatedId);
// Remove all relations of a type
$product->relatedProducts()->detach();
// Sync relations (replace all)
$product->productRelations()->sync([
$id1 => ['type' => ProductRelationType::RELATED->value],
$id2 => ['type' => ProductRelationType::RELATED->value],
]);
Relation Type Reference
| Type | Enum | Direction | Auto-Reverse | Typical Use |
|---|---|---|---|---|
| RELATED | ProductRelationType::RELATED |
One-way | No | Similar products, "also viewed" |
| UPSELL | ProductRelationType::UPSELL |
One-way | No | Premium alternatives |
| CROSS_SELL | ProductRelationType::CROSS_SELL |
One-way | No | Complementary products |
| DOWNSELL | ProductRelationType::DOWNSELL |
One-way | No | Lower-priced alternatives |
| ADD_ON | ProductRelationType::ADD_ON |
One-way | No | Optional extras |
| VARIATION | ProductRelationType::VARIATION |
One-way | No | Product variants |
| BUNDLE | ProductRelationType::BUNDLE |
One-way | No | Package components |
| SINGLE | ProductRelationType::SINGLE |
Pool → Item | Yes (POOL) | Pool single items |
| POOL | ProductRelationType::POOL |
Item → Pool | Yes (SINGLE) | Item's pool reference |
Best Practices
1. Use Appropriate Relation Types
// ✅ CORRECT - Semantic meaning
$product->productRelations()->attach($accessory->id, [
'type' => ProductRelationType::CROSS_SELL->value // Complementary
]);
// ❌ INCORRECT - Wrong type
$product->productRelations()->attach($accessory->id, [
'type' => ProductRelationType::UPSELL->value // Accessory isn't an upgrade
]);
2. Use Helper Methods
// ✅ CORRECT - Dedicated method
$upsells = $product->upsellProducts;
// ❌ VERBOSE - Manual filtering
$upsells = $product->productRelations()
->wherePivot('type', ProductRelationType::UPSELL->value)
->get();
3. Sort Order for Display
// ✅ CORRECT - Use sort_order
$product->productRelations()->attach($items, [
$item1->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 1],
$item2->id => ['type' => ProductRelationType::CROSS_SELL->value, 'sort_order' => 2],
]);
$crossSells = $product->crossSellProducts()->orderByPivot('sort_order')->get();
4. Always Use attachSingleItems() for Pools
// ✅ CORRECT - Bidirectional
$pool->attachSingleItems([$item1->id, $item2->id]);
// ❌ INCORRECT - One-way only
$pool->productRelations()->attach($item1->id, [
'type' => ProductRelationType::SINGLE->value
]);
5. Eager Load Relations
// ✅ CORRECT - Avoid N+1
$products = Product::with('crossSellProducts')->get();
foreach ($products as $product) {
$product->crossSellProducts; // Already loaded
}
// ❌ INCORRECT - N+1 queries
$products = Product::all();
foreach ($products as $product) {
$product->crossSellProducts; // Query per product
}
6. Validate Relation Logic
// ✅ CORRECT - Check business logic
if ($premiumPlan->price > $basicPlan->price) {
$basicPlan->productRelations()->attach($premiumPlan->id, [
'type' => ProductRelationType::UPSELL->value
]);
}
// ❌ INCORRECT - No validation
$basicPlan->productRelations()->attach($cheaperPlan->id, [
'type' => ProductRelationType::UPSELL->value // Upsell to cheaper product??
]);
Common Patterns
Product Page Relations Display
// Show all related products
$relatedProducts = $product->relatedProducts()->limit(4)->get();
// Show upsell if available
$upsell = $product->upsellProducts()->first();
// Show add-ons
$addOns = $product->addOnProducts;
Cart Cross-Sells
$cart = $user->currentCart();
$allCrossSells = collect();
foreach ($cart->items as $item) {
$crossSells = $item->purchasable->crossSellProducts;
$allCrossSells = $allCrossSells->merge($crossSells);
}
// Remove duplicates and products already in cart
$uniqueCrossSells = $allCrossSells
->unique('id')
->reject(fn($p) => $cart->items->contains('purchasable_id', $p->id));
Upsell at Checkout
$cartTotal = $cart->getTotal();
// Find upsells for products in cart
$upsellOpportunities = $cart->items
->map(fn($item) => $item->purchasable->upsellProducts)
->flatten()
->unique('id');
// Filter to affordable upsells
$affordableUpsells = $upsellOpportunities->filter(
fn($upsell) => $upsell->price <= $cartTotal * 1.2 // Max 20% more
);
Bundle Product Display
// Show bundle contents
$bundle = Product::find($id);
$components = $bundle->bundleProducts;
$totalValue = $components->sum('price');
$bundlePrice = $bundle->price;
$savings = $totalValue - $bundlePrice;
// "Save $50 when you buy the bundle!"
Troubleshooting
Relations Not Showing
Check:
- Relation type is correct:
ProductRelationType::RELATED->value - Using correct method:
relatedProductsnotproductRelations - Pivot data exists: Check
product_relationstable
Pool/Single Relations Not Bidirectional
Solution:
// Use dedicated method
$pool->attachSingleItems($itemIds);
// NOT regular attach()
Duplicate Relations
Prevent:
// Check before adding
if (!$product->relatedProducts()->where('id', $relatedId)->exists()) {
$product->productRelations()->attach($relatedId, [
'type' => ProductRelationType::RELATED->value
]);
}
N+1 Query Issues
Solution:
// Eager load
$products = Product::with([
'crossSellProducts',
'upsellProducts',
'relatedProducts'
])->get();
Related Documentation
- Pool Products - POOL/SINGLE relations in detail
- Product Types - Understanding different product types
- Stock Management - How stock works with relations