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: 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:

Extending the Model

To add custom behavior, extend the admin model and update your configuration:
Update config/shopper/models.php:
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

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.

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:
To replace all attribute values at once:

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:
To get stock for a specific inventory location:

Modifying Stock

To increase stock (for example, when receiving a shipment):
To decrease stock (for example, when fulfilling an order):
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:
To clear all stock history and optionally set a new starting quantity:
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:

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:
To batch-load stock for a specific inventory location:
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().
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:
To get the price for a specific currency:
The returned Price model contains three amount fields:
To format a price for display:

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”. To add a variant thumbnail:
To add gallery images:
To retrieve the thumbnail URL with a specific conversion:

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:
Length (used for height, width, and depth):
Volume:

Creating Variants

Single Variant

The simplest way to create a variant with pricing, stock, and attribute values in one transaction is the CreateNewVariant action:
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:

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:

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.
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():
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:
To find a variant by its SKU:
To load variants with their attribute values and parent attributes (for building a selection UI):
To find the variant matching a specific combination of attribute values (for example, when a customer selects Color = Blue and Size = Large):
To get the total stock across all variants for a product:

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:
Variant-related components in config/shopper/components/product.php:

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.