Skip to main content
Product variants represent specific versions of a product based on combinations of options like size, color, or material. A “Classic T-Shirt” product might have six variants: Blue/S, Blue/M, Blue/L, Red/S, Red/M, Red/L. Each variant carries its own SKU, price, stock level, images, and shipping dimensions.

How Variants Work

Shopper uses an attribute-based variant system. A product with type = ProductType::Variant acts as a parent that holds the shared description, categories, and brand. The parent delegates pricing and stock to its child variants. The relationship works like this:
LevelExampleWhat it holds
ProductClassic T-ShirtName, description, categories, brand, images
Option (Attribute)Color, SizeThe axes that define variation
Option Value (Attribute Value)Blue, Red, S, M, LThe choices within each option
VariantBlue / S, Blue / M, Red / LSKU, price, stock, dimensions, images
A variant’s identity is the combination of its attribute values. The variant “Blue / Large” is defined by two values: Color = Blue and Size = Large. Shopper stores this through a pivot table that links variants to attribute values, so the system can look up which variant matches a customer’s selection.
The parent product itself has no price or stock when it uses variants. All pricing and inventory queries go through the variants.

Model

The model used is Shopper\Models\ProductVariant, which extends Shopper\Core\Models\ProductVariant. The core model provides the business logic, relationships, stock management, and pricing. The admin model adds media collections and conversions through Spatie MediaLibrary. The core model implements Shopper\Core\Contracts\Priceable and Shopper\Core\Models\Contracts\ProductVariant, and uses the following traits:
TraitPurpose
HasDimensionsWeight, height, width, depth, volume with unit enums
HasMediaCollectionsVariant-specific images via Spatie MediaLibrary
HasPricesMulti-currency pricing through a polymorphic prices relationship
HasStockInventory management through inventory_histories mutations

Extending the Model

To add custom behavior, extend the admin model and update your configuration:
namespace App\Models;

use Shopper\Models\ProductVariant as ShopperProductVariant;

class ProductVariant extends ShopperProductVariant
{
}
Update config/shopper/models.php:
return [
    'variant' => \App\Models\ProductVariant::class,
];
Shopper resolves the variant class through this config key, so all internal queries and relationships use your extended model automatically.

Database Schema

Product Variants Table

ColumnTypeNullableDefaultDescription
idbigintnoautoPrimary key
namestringno-Variant name, typically generated from attribute values (indexed)
skustringyesnullStock Keeping Unit (unique per product)
barcodestringyesnullBarcode (unique)
eanstringyesnullEuropean Article Number (unique)
upcstringyesnullUniversal Product Code (unique)
allow_backorderbooleannofalseWhether customers can order when out of stock
positionintegerno1Display order among siblings
product_idbigintno-Foreign key to the parent 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 for your application
created_attimestampyesnullCreation timestamp
updated_attimestampyesnullLast update timestamp
The sku column uses a composite unique constraint on [product_id, sku]. Two variants on different products can share the same SKU, but variants within the same product must have distinct SKUs.

Attribute Value Pivot Table

This pivot table connects variants to their attribute values, defining which combination of options each variant represents.
ColumnTypeNullableDescription
idbigintnoPrimary key
value_idbigintnoForeign key to attribute value
variant_idbigintnoForeign key to variant

Relationships

Product

Each variant belongs to a parent product. This is the inverse of the Product::variants() relationship.
$variant->product;

Attribute Values

The values() relationship defines the variant’s identity. A variant named “Blue / Large” has two attribute values attached: one for Color (Blue) and one for Size (Large). This is how the system matches a customer’s selection to a specific variant.
$variant->values;
To attach attribute values when creating a variant:
$variant->values()->attach([
    $colorBlueValueId,
    $sizeLargeValueId,
]);
To replace all attribute values at once:
$variant->values()->sync([$colorRedValueId, $sizeMediumValueId]);

Stock Management

Variants use the HasStock trait for inventory management. Stock is not stored as a column on the variant. Every stock change creates a record in the inventory_histories table, and the current stock is computed as the sum of all mutations. This gives you a complete audit trail of every stock movement. Every stock mutation is tied to an inventory location (warehouse, store, fulfillment center), so you must always provide an $inventoryId.

Querying Stock

To get the current total stock across all locations:
$variant->stock;
To get stock at a specific point in time, useful for reporting or auditing:
$variant->getStock('2026-01-15');
$variant->getStock(now()->subDays(7));
To get stock for a specific inventory location:
$variant->stockInventory($warehouseId);
$variant->stockInventory($warehouseId, '2026-01-15');

Modifying Stock

To increase stock (for example, when receiving a shipment):
use Shopper\Models\ProductVariant;

$variant = ProductVariant::query()->find($id);

$variant->mutateStock(
    inventoryId: $inventory->id,
    quantity: 50,
    event: 'Shipment received',
    description: 'PO #12345 - Spring collection restock',
);
To decrease stock (for example, when fulfilling an order):
$variant->decreaseStock(
    inventoryId: $inventory->id,
    quantity: 2,
    event: 'Order fulfilled',
    description: 'Order #1042',
);
To set stock to an exact quantity (for example, after a physical inventory count). This calculates the delta from the current stock and creates a single mutation:
$variant->setStock(
    newQuantity: 75,
    inventoryId: $inventory->id,
    event: 'Inventory count',
    description: 'Q1 2026 physical count adjustment',
);
To clear all stock history and optionally set a new starting quantity:
$variant->clearStock(
    inventoryId: $inventory->id,
    newQuantity: 100,
    event: 'Stock reset',
);
clearStock() deletes all inventory history records for the variant. Use it only for resets or corrections, not for regular stock adjustments.

Backorder

The allow_backorder flag indicates whether customers can purchase this variant when it is out of stock. The inStock() method does not check this flag. It only checks whether quantity is available. Your storefront logic should combine both:
if ($variant->inStock() || $variant->allow_backorder) {
    // allow add to cart
}

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\Models\ProductVariant;

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

ProductVariant::loadCurrentStock($product->variants);

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, enable lazy stock loading prevention. When enabled, accessing $variant->stock without prior batch-loading throws a LazyStockLoadingException with a clear message telling you to use loadCurrentStock().
ProductVariant::preventLazyStockLoading(! app()->isProduction());
For more details on the batch-loading pattern, see the Products Stock Management section.

Pricing

Variants use the HasPrices trait for multi-currency pricing. Each variant can have one price per currency, stored in the prices table through a polymorphic relationship. To get the price for the store’s default currency:
$price = $variant->getPrice();
To get the price for a specific currency:
$price = $variant->getPrice('USD');
The returned Price model contains three amount fields:
$price->amount;         // selling price, e.g. 2999
$price->compare_amount; // original/compare-at price, e.g. 3999
$price->cost_amount;    // cost price for margin tracking, e.g. 1500
To format a price for display:
shopper_money_format($price->amount, $price->currency_code);

Media

Variants support two media collections through Spatie MediaLibrary, using the same config-driven collection names as products. This lets you show a different photo when a customer selects “Blue” versus “Red”.
CollectionConfig keyBehaviorDescription
Default galleryshopper.media.storage.collection_nameMultiple filesVariant gallery images
Thumbnailshopper.media.storage.thumbnail_collectionSingle fileMain variant image for listings
To add a variant thumbnail:
$collection = config('shopper.media.storage.thumbnail_collection');

$variant->addMedia($file)->toMediaCollection($collection);
To add gallery images:
$collection = config('shopper.media.storage.collection_name');

$variant->addMedia($file)->toMediaCollection($collection);
To retrieve the thumbnail URL with a specific conversion:
$collection = config('shopper.media.storage.thumbnail_collection');

$url = $variant->getFirstMediaUrl($collection, 'medium');

Dimensions

The HasDimensions trait provides physical measurements for shipping calculations. Each dimension has a value and a unit stored as an enum.

Dimension Enums

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

Weight::KG   // kg - Kilograms
Weight::G    // g - Grams
Weight::LBS  // lbs - Pounds
Length (used for height, width, and 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

Creating Variants

Single Variant

The simplest way to create a variant with pricing, stock, and attribute values in one transaction is the CreateNewVariant action:
use Shopper\Actions\Store\Product\CreateNewVariant;

$variant = app()->call(CreateNewVariant::class, ['data' => [
    'name' => 'Blue / Large',
    'sku' => 'TSHIRT-BLU-L',
    'product_id' => $product->id,
    'position' => 1,
    'quantity' => 50,
    'values' => [$colorBlueValueId, $sizeLargeValueId],
    'prices' => [
        ['amount' => 2999, 'currency_id' => $currencyId],
    ],
]]);
This action wraps everything in a database transaction: it creates the variant, saves pricing, syncs attribute values, and sets initial stock on the default inventory location.

Manual Creation

For more control, create the variant step by step:
use Shopper\Models\ProductVariant;
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,
    'position' => 1,
    'weight_unit' => Weight::G,
    'weight_value' => 250,
    'height_unit' => Length::CM,
    'height_value' => 70,
    'width_unit' => Length::CM,
    'width_value' => 50,
]);

$variant->values()->attach([$colorBlueValueId, $sizeLargeValueId]);

$variant->prices()->create([
    'amount' => 2999,
    'currency_id' => $currencyId,
]);

$variant->mutateStock(
    inventoryId: $inventory->id,
    quantity: 50,
    event: 'Initial stock',
);

Generating Variant Combinations

When a product has multiple options, you need to generate all possible combinations. A product with 3 colors and 3 sizes produces 9 variants. Shopper provides tools to automate this.

Permutation Helpers

The Arr macro generates the cartesian product of option values:
use Shopper\Core\Macros\Arr;

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

$permutations = Arr::permutate($options);
// Result: 4 combinations - Blue/Medium, Blue/Large, Red/Medium, Red/Large

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

$valueIds = Arr::getPermutationIds($permutations[0]);
// [1, 4]

Bulk Save with SaveProductVariantsAction

The SaveProductVariantsAction handles creating, updating, and deleting variants in a single transaction. Variants not present in the array are deleted, existing ones are updated, and new ones are created.
use Shopper\Actions\Store\Product\SaveProductVariantsAction;

$variants = [
    [
        'variant_id' => null,
        'name' => 'Blue / Large',
        'sku' => 'TSHIRT-BLUE-LARGE',
        'price' => 29.99,
        'stock' => 100,
        'values' => [1, 5],
    ],
    [
        'variant_id' => null,
        'name' => 'Blue / Medium',
        'sku' => 'TSHIRT-BLUE-MEDIUM',
        'price' => 29.99,
        'stock' => 50,
        'values' => [1, 4],
    ],
];

$savedVariants = app()->call(SaveProductVariantsAction::class, [
    'product' => $product,
    'variants' => $variants,
]);
Pass an existing variant_id to update a variant instead of creating a new one. Any variant belonging to the product that is not in the array will be deleted.

MapProductOptions

For products with existing attribute assignments, MapProductOptions builds the structured options array you can feed into Arr::permutate():
use Shopper\Helpers\MapProductOptions;

$options = MapProductOptions::generate($product);
This returns an array of options with their IDs, names, and available values based on the attributes already assigned to the product.

Retrieving Variants

To get all variants for a product, ordered by position:
$variants = $product->variants()->orderBy('position')->get();
To find a variant by its SKU:
use Shopper\Models\ProductVariant;

$variant = ProductVariant::query()
    ->where('sku', 'TSHIRT-BLU-L')
    ->first();
To load variants with their attribute values and parent attributes (for building a selection UI):
$variants = $product->variants()
    ->with('values.attribute')
    ->orderBy('position')
    ->get();
To find the variant matching a specific combination of attribute values (for example, when a customer selects Color = Blue and Size = Large):
$valueIds = [$colorBlueId, $sizeLargeId];

$variant = $product->variants()
    ->whereHas('values', function ($q) use ($valueIds) {
        $q->whereIn('id', $valueIds);
    }, '=', count($valueIds))
    ->first();
To get the total stock across all variants for a product:
$product->variants_stock;

Observer Behavior

The ProductVariantObserver handles cascade cleanup when a variant is deleted. It removes all associated media files, prices, and inventory history records automatically.
$variant->delete();
You do not need to manually clean up related records before deleting a variant. If you extend the model and override the observer, call parent::deleting() to preserve this behavior.

Components

You can publish the Livewire components to customize the admin UI for variant management:
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

This example shows a complete product page controller that loads variants, groups their attribute values for a selection UI, and handles AJAX variant lookups when a customer changes their selection.
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Shopper\Models\Product;
use Shopper\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();

        ProductVariant::loadCurrentStock($variants);

        $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);
            }
        }

        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'),
        ]);
    }
}