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:
| Level | Example | What it holds |
|---|
| Product | Classic T-Shirt | Name, description, categories, brand, images |
| Option (Attribute) | Color, Size | The axes that define variation |
| Option Value (Attribute Value) | Blue, Red, S, M, L | The choices within each option |
| Variant | Blue / S, Blue / M, Red / L | SKU, 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:
| Trait | Purpose |
|---|
HasDimensions | Weight, height, width, depth, volume with unit enums |
HasMediaCollections | Variant-specific images via Spatie MediaLibrary |
HasPrices | Multi-currency pricing through a polymorphic prices relationship |
HasStock | Inventory 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
| Column | Type | Nullable | Default | Description |
|---|
id | bigint | no | auto | Primary key |
name | string | no | - | Variant name, typically generated from attribute values (indexed) |
sku | string | yes | null | Stock Keeping Unit (unique per product) |
barcode | string | yes | null | Barcode (unique) |
ean | string | yes | null | European Article Number (unique) |
upc | string | yes | null | Universal Product Code (unique) |
allow_backorder | boolean | no | false | Whether customers can order when out of stock |
position | integer | no | 1 | Display order among siblings |
product_id | bigint | no | - | Foreign key to the parent product |
weight_unit | string | no | kg | Weight unit enum |
weight_value | decimal(10,2) | yes | null | Weight value |
height_unit | string | no | cm | Height unit enum |
height_value | decimal(10,2) | yes | null | Height value |
width_unit | string | no | cm | Width unit enum |
width_value | decimal(10,2) | yes | null | Width value |
depth_unit | string | no | cm | Depth unit enum |
depth_value | decimal(10,2) | yes | null | Depth value |
volume_unit | string | no | l | Volume unit enum |
volume_value | decimal(10,2) | yes | null | Volume value |
metadata | json | yes | null | Additional custom data for your application |
created_at | timestamp | yes | null | Creation timestamp |
updated_at | timestamp | yes | null | Last 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.
| Column | Type | Nullable | Description |
|---|
id | bigint | no | Primary key |
value_id | bigint | no | Foreign key to attribute value |
variant_id | bigint | no | Foreign key to variant |
Relationships
Product
Each variant belongs to a parent product. This is the inverse of the Product::variants() relationship.
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.
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:
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);
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”.
| Collection | Config key | Behavior | Description |
|---|
| Default gallery | shopper.media.storage.collection_name | Multiple files | Variant gallery images |
| Thumbnail | shopper.media.storage.thumbnail_collection | Single file | Main 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.
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'),
]);
}
}