laravel-shop/docs/05-product-relations.md

667 lines
17 KiB
Markdown
Raw Normal View History

2025-12-16 12:58:03 +00:00
# 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
1. **Directional Relations**: Relations go from `product_id` to `related_product_id`
2. **Typed Relations**: Each relation has a specific type (e.g., UPSELL, RELATED)
3. **Flexible**: Same product can have multiple relation types to different products
4. **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**:
```php
// 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**:
```php
// 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**:
```php
// 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**:
```php
// 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**:
```php
// 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**:
```php
// 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**:
```php
// 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**:
```php
// 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**:
```php
// 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
```php
// 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
```php
// 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
```php
// 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
```php
// 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:
```php
// ✅ 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:**
```php
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
```php
// 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
```php
// 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
```php
// 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
```php
// 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
```php
// ✅ 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
```php
// ✅ CORRECT - Dedicated method
$upsells = $product->upsellProducts;
// ❌ VERBOSE - Manual filtering
$upsells = $product->productRelations()
->wherePivot('type', ProductRelationType::UPSELL->value)
->get();
```
### 3. Sort Order for Display
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// ✅ 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
```php
// 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
```php
$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
```php
$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
```php
// 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:**
1. Relation type is correct: `ProductRelationType::RELATED->value`
2. Using correct method: `relatedProducts` not `productRelations`
3. Pivot data exists: Check `product_relations` table
### Pool/Single Relations Not Bidirectional
**Solution:**
```php
// Use dedicated method
$pool->attachSingleItems($itemIds);
// NOT regular attach()
```
### Duplicate Relations
**Prevent:**
```php
// Check before adding
if (!$product->relatedProducts()->where('id', $relatedId)->exists()) {
$product->productRelations()->attach($relatedId, [
'type' => ProductRelationType::RELATED->value
]);
}
```
### N+1 Query Issues
**Solution:**
```php
// Eager load
$products = Product::with([
'crossSellProducts',
'upsellProducts',
'relatedProducts'
])->get();
```
## Related Documentation
- [Pool Products](./ProductTypes/02-pool-products.md) - POOL/SINGLE relations in detail
- [Product Types](./ProductTypes/) - Understanding different product types