Skip to main content
Product Variants represent different versions of a product based on attributes like size, color, or material. Each variant can have its own SKU, price, stock level, and physical dimensions.

Model

Shopper\Core\Models\ProductVariant
use Shopper\Core\Models\ProductVariant;

// The model implements
- Shopper\Core\Models\Contracts\ProductVariant
- Shopper\Core\Contracts\Priceable
- Spatie\MediaLibrary\HasMedia

// Uses traits
- HasDimensions
- HasMedia
- HasPrices
- HasStock

Extending the Model

namespace App\Models;

use Shopper\Core\Models\ProductVariant as BaseProductVariant;

class ProductVariant extends BaseProductVariant
{
    // Add your customizations here
}
Update config/shopper/models.php:
return [
    'variant' => \App\Models\ProductVariant::class,
];

Database Schema

ProductVariant Table

ColumnTypeNullableDefaultDescription
idbigintnoautoPrimary key
namestringno-Variant name (indexed)
skustringyesnullStock Keeping Unit (unique)
barcodestringyesnullBarcode (unique)
eanstringyesnullEuropean Article Number (unique)
upcstringyesnullUniversal Product Code (unique)
allow_backorderbooleannofalseAllow ordering when out of stock
positionintegerno1Display order
product_idbigintno-FK to product
weight_unitstringnokgWeight unit enum
weight_valuedecimal(10,2)yesnullWeight value
height_unitstringnocmHeight unit enum
height_valuedecimal(10,2)yesnullHeight value
width_unitstringnocmWidth unit enum
width_valuedecimal(10,2)yesnullWidth value
depth_unitstringnocmDepth unit enum
depth_valuedecimal(10,2)yesnullDepth value
volume_unitstringnolVolume unit enum
volume_valuedecimal(10,2)yesnullVolume value
metadatajsonyesnullAdditional custom data
created_attimestampyesnullCreation timestamp
updated_attimestampyesnullLast update timestamp

Attribute Value Variant Table (Pivot)

ColumnTypeNullableDescription
idbigintnoPrimary key
value_idbigintnoFK to attribute value
variant_idbigintnoFK to variant

Dimension Enums

Weight

use Shopper\Core\Enum\Dimension\Weight;

Weight::KG   // kg - Kilograms
Weight::G    // g - Grams
Weight::LBS  // lbs - Pounds

Length (Height, Width, Depth)

use Shopper\Core\Enum\Dimension\Length;

Length::M    // m - Meters
Length::CM   // cm - Centimeters
Length::MM   // mm - Millimeters
Length::FT   // ft - Feet
Length::IN   // in - Inches

Volume

use Shopper\Core\Enum\Dimension\Volume;

Volume::L    // l - Liters
Volume::ML   // ml - Milliliters
Volume::GAL  // gal - Gallons

Relationships

Product

// Get the parent product
$variant->product;

Attribute Values

// Get all attribute values for this variant
$variant->values; // Collection of AttributeValue models

// Example: Blue / Large variant
// Returns: Color: Blue, Size: Large

// Link attribute values to variant
$variant->values()->attach([
    $blueValueId,
    $largeValueId,
]);

// Sync attribute values
$variant->values()->sync([$valueId1, $valueId2]);

Stock Management

Variants use the HasStock trait for inventory:
// Get current stock
$variant->stock; // Integer

// Increase stock
$variant->increaseStock(10);

// Decrease stock
$variant->decreaseStock(5);

// Set stock
$variant->setStock(100);

// Check if in stock
$variant->inStock(); // true/false

// Check if allows backorder
$variant->allow_backorder;

Avoiding N+1 Queries

When displaying a product page with multiple variants, accessing $variant->stock on each variant triggers an individual query. Use loadCurrentStock() to batch-load stock in a single query:
use Shopper\Core\Models\ProductVariant;

$product = Product::query()->with('variants')->find($id);

// 1 query for all variants instead of 1 per variant
ProductVariant::loadCurrentStock($product->variants);

// Stock is now preloaded — no additional queries
foreach ($product->variants as $variant) {
    echo $variant->name . ': ' . $variant->stock;
}
To batch-load stock for a specific inventory location:
ProductVariant::loadStockForInventory($product->variants, $inventoryId);
To catch unoptimized stock access during development:
// In a service provider boot() method
ProductVariant::preventLazyStockLoading(! app()->isProduction());
When enabled, accessing $variant->stock without prior batch-loading throws a LazyStockLoadingException with a clear message telling you to use loadCurrentStock(). For more details on the batch-loading pattern, see the Products Stock Management section.

Pricing

Variants use the HasPrices trait:
// Get all prices (from prices table)
$variant->prices; // Collection of Price models

// Get price for default currency (from shopper_currency())
$price = $variant->getPrice(); // ?Price model

// Get price for a specific currency
$price = $variant->getPrice('USD'); // ?Price model

// Access price amounts (automatically converted from cents)
$price->amount;         // e.g., 29.99
$price->compare_amount; // e.g., 39.99 (original price)
$price->cost_amount;    // e.g., 15.00 (cost price)

// Get formatted price using Price helper
$price->amountPrice();        // Shopper\Core\Helpers\Price object
$price->amountPrice()->formatted; // "$29.99"

Media Management

Variants use the HasMedia trait for images:
// Add image to variant
$variant->addMedia($file)->toMediaCollection('images');

// Get variant images
$variant->getMedia('images');

// Get first image URL
$variant->getFirstMediaUrl('images');

Creating Variants

Basic Variant

use Shopper\Core\Models\ProductVariant;
use Shopper\Core\Models\Product;

$product = Product::query()->find($id);

$variant = ProductVariant::query()->create([
    'name' => 'Blue / Large',
    'sku' => 'TSHIRT-BLU-L',
    'product_id' => $product->id,
    'position' => 1,
]);

Variant with Dimensions

use Shopper\Core\Enum\Dimension\Weight;
use Shopper\Core\Enum\Dimension\Length;

$variant = ProductVariant::query()->create([
    'name' => 'Blue / Large',
    'sku' => 'TSHIRT-BLU-L',
    'product_id' => $product->id,
    'weight_unit' => Weight::G,
    'weight_value' => 250,
    'height_unit' => Length::CM,
    'height_value' => 70,
    'width_unit' => Length::CM,
    'width_value' => 50,
]);

Variant with Attribute Values

$variant = ProductVariant::query()->create([
    'name' => 'Blue / Large',
    'sku' => 'TSHIRT-BLU-L',
    'product_id' => $product->id,
]);

// Link to attribute values (Color: Blue, Size: Large)
$variant->values()->attach([
    $colorBlueValueId,
    $sizeLargeValueId,
]);

Generate Variant Name from Attributes

// Helper to generate variant name from attribute values
function generateVariantName(array $valueIds): string
{
    return AttributeValue::query()->whereIn('id', $valueIds)
        ->get()
        ->pluck('value')
        ->implode(' / ');
}

$name = generateVariantName([$colorBlueId, $sizeLargeId]); // "Blue / Large"

Retrieving Variants

use Shopper\Core\Models\ProductVariant;

// Get all variants for a product
$variants = ProductVariant::query()
    ->where('product_id', $productId)
    ->orderBy('position')
    ->get();

// Get variants with stock
$inStock = ProductVariant::query()
    ->where('product_id', $productId)
    ->where('stock', '>', 0)
    ->get();

// Get variant by SKU
$variant = ProductVariant::query()
    ->where('sku', 'TSHIRT-BLU-L')
    ->first();

// Get variants with attribute values
$variants = ProductVariant::query()
    ->where('product_id', $productId)
    ->with('values.attribute')
    ->get();

// Find variant by attribute values
$variant = ProductVariant::query()
    ->where('product_id', $productId)
    ->whereHas('values', function ($q) use ($valueIds) {
        $q->whereIn('id', $valueIds);
    }, '=', count($valueIds))
    ->first();

Observer Behavior

The ProductVariantObserver handles cleanup when deleting:
// When a variant is deleted:
// 1. Media files are deleted
// 2. Prices are deleted
// 3. Stock records are cleared

$variant->delete(); // Triggers observer cleanup

Working with Products

use Shopper\Core\Models\Product;
use Shopper\Core\Enum\ProductType;

// Create a variant product
$product = Product::query()->create([
    'name' => 'Classic T-Shirt',
    'type' => ProductType::Variant,
    // ...
]);

// Get all variants
$product->variants;

// Get total stock across all variants
$totalStock = $product->variants->sum('stock');

// Get variant by attributes
$variant = $product->variants()
    ->whereHas('values', fn ($q) => $q->where('id', $colorBlueId))
    ->whereHas('values', fn ($q) => $q->where('id', $sizeLargeId))
    ->first();

Variant Matrix Generation

Shopper provides built-in tools to generate all possible variant combinations from product attributes.

Using SaveProductVariantsAction

use Shopper\Actions\Store\Product\SaveProductVariantsAction;
use Shopper\Core\Macros\Arr;

// Prepare variants array with this structure
$variants = [
    [
        'variant_id' => null, // null for new, or existing ID to update
        'name' => 'Blue / Large',
        'sku' => 'TSHIRT-BLUE-LARGE',
        'price' => 29.99,
        'stock' => 100,
        'values' => [1, 5], // Attribute value IDs (Color: Blue, Size: Large)
    ],
    [
        'variant_id' => null,
        'name' => 'Blue / Medium',
        'sku' => 'TSHIRT-BLUE-MEDIUM',
        'price' => 29.99,
        'stock' => 50,
        'values' => [1, 4], // Color: Blue, Size: Medium
    ],
];

// Save variants (creates new, updates existing, deletes removed)
$savedVariants = app()->call(SaveProductVariantsAction::class, [
    'product' => $product,
    'variants' => $variants,
]);

Generate Permutations with Arr::permutate

use Shopper\Core\Macros\Arr;

// Define options with their values
$options = [
    'Color' => [
        ['id' => 1, 'value' => 'Blue'],
        ['id' => 2, 'value' => 'Red'],
    ],
    'Size' => [
        ['id' => 4, 'value' => 'Medium'],
        ['id' => 5, 'value' => 'Large'],
    ],
];

// Generate all combinations (cartesian product)
$permutations = Arr::permutate($options);
// Result: Blue/Medium, Blue/Large, Red/Medium, Red/Large

// Build variant name from permutation
$name = Arr::performPermutationIntoWord($permutations[0], 'value'); // "Blue / Medium"

// Get attribute value IDs from permutation
$valueIds = Arr::getPermutationIds($permutations[0]); // [1, 4]

MapProductOptions Helper

For products with existing attribute assignments, use MapProductOptions:
use Shopper\Helpers\MapProductOptions;

// Get structured options from product's assigned attributes
$options = MapProductOptions::generate($product);
// Returns array with id, name, and values for each attribute

Components

php artisan shopper:component:publish product
Variant-related components in config/shopper/components/product.php:
use Shopper\Livewire;
use Shopper\Livewire\Components;

return [
    'pages' => [
        'variant-edit' => Livewire\Pages\Product\Variant::class,
    ],
    'components' => [
        'products.form.variants' => Components\Products\Form\Variants::class,
        'products.variant-stock' => Components\Products\VariantStock::class,
        'slide-overs.add-variant' => Livewire\SlideOvers\AddVariant::class,
        'slide-overs.update-variant' => Livewire\SlideOvers\UpdateVariant::class,
        'slide-overs.generate-variants' => Livewire\SlideOvers\GenerateVariants::class,
    ],
];

Storefront Example

namespace App\Http\Controllers;

use Shopper\Core\Models\Product;
use Shopper\Core\Models\ProductVariant;

class ProductController extends Controller
{
    public function show(string $slug)
    {
        $product = Product::query()
            ->where('slug', $slug)
            ->publish()
            ->firstOrFail();

        $variants = $product->variants()
            ->with(['values.attribute', 'media'])
            ->orderBy('position')
            ->get();

        // Group attribute values for selection UI
        $attributes = [];
        foreach ($variants as $variant) {
            foreach ($variant->values as $value) {
                $attrId = $value->attribute_id;
                if (! isset($attributes[$attrId])) {
                    $attributes[$attrId] = [
                        'name' => $value->attribute->name,
                        'values' => collect(),
                    ];
                }
                $attributes[$attrId]['values']->push($value);
            }
        }

        // Deduplicate values
        foreach ($attributes as &$attr) {
            $attr['values'] = $attr['values']->unique('id');
        }

        return view('products.show', compact('product', 'variants', 'attributes'));
    }

    public function getVariant(Request $request, Product $product)
    {
        $valueIds = $request->input('values', []);

        $variant = $product->variants()
            ->whereHas('values', function ($q) use ($valueIds) {
                $q->whereIn('id', $valueIds);
            }, '=', count($valueIds))
            ->with('media')
            ->first();

        if (! $variant) {
            return response()->json(['error' => 'Variant not found'], 404);
        }

        return response()->json([
            'id' => $variant->id,
            'name' => $variant->name,
            'sku' => $variant->sku,
            'price' => $variant->getPrice()?->amount,
            'stock' => $variant->stock,
            'in_stock' => $variant->inStock(),
            'image' => $variant->getFirstMediaUrl('images'),
        ]);
    }
}

Use Cases

ScenarioExample
Color variantsT-shirt in Red, Blue, Green
Size variantsShoes in sizes 8, 9, 10, 11
Combined variantsT-shirt in Red/Small, Red/Medium, Blue/Small, etc.
Material variantsBag in Leather, Canvas, Nylon
TraitPurpose
HasStockInventory management
HasPricesCurrency-specific pricing
HasMediaVariant-specific images
HasDimensionsShipping calculations