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

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

  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:

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:

  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:

// 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();