> ## Documentation Index
> Fetch the complete documentation index at: https://docs.laravelshopper.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Product Variants

> Manage product variants with per-option pricing, stock, and media in Shopper.

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.

<Note>
  The parent product itself has no price or stock when it uses variants. All pricing and inventory queries go through the variants.
</Note>

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

```php theme={null}
namespace App\Models;

use Shopper\Models\ProductVariant as ShopperProductVariant;

class ProductVariant extends ShopperProductVariant
{
}
```

Update `config/shopper/models.php`:

```php theme={null}
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.

```php theme={null}
$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.

```php theme={null}
$variant->values;
```

To attach attribute values when creating a variant:

```php theme={null}
$variant->values()->attach([
    $colorBlueValueId,
    $sizeLargeValueId,
]);
```

To replace all attribute values at once:

```php theme={null}
$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:

```php theme={null}
$variant->stock;
```

To get stock at a specific point in time, useful for reporting or auditing:

```php theme={null}
$variant->getStock('2026-01-15');
$variant->getStock(now()->subDays(7));
```

To get stock for a specific inventory location:

```php theme={null}
$variant->stockInventory($warehouseId);
$variant->stockInventory($warehouseId, '2026-01-15');
```

### Modifying Stock

To increase stock (for example, when receiving a shipment):

```php theme={null}
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):

```php theme={null}
$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:

```php theme={null}
$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:

```php theme={null}
$variant->clearStock(
    inventoryId: $inventory->id,
    newQuantity: 100,
    event: 'Stock reset',
);
```

<Warning>
  `clearStock()` deletes all inventory history records for the variant. Use it only for resets or corrections, not for regular stock adjustments.
</Warning>

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

```php theme={null}
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:

```php theme={null}
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:

```php theme={null}
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()`.

```php theme={null}
ProductVariant::preventLazyStockLoading(! app()->isProduction());
```

For more details on the batch-loading pattern, see the [Products Stock Management](/v2/products#avoiding-n1-queries) 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:

```php theme={null}
$price = $variant->getPrice();
```

To get the price for a specific currency:

```php theme={null}
$price = $variant->getPrice('USD');
```

The returned `Price` model contains three amount fields:

```php theme={null}
$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:

```php theme={null}
shopper_money_format($price->amount, $price->currency_code);
```

## Media

Variants support two media collections through [Spatie MediaLibrary](https://spatie.be/docs/laravel-medialibrary), using the same config-driven collection names as [products](/v2/products#media). 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:

```php theme={null}
$collection = config('shopper.media.storage.thumbnail_collection');

$variant->addMedia($file)->toMediaCollection($collection);
```

To add gallery images:

```php theme={null}
$collection = config('shopper.media.storage.collection_name');

$variant->addMedia($file)->toMediaCollection($collection);
```

To retrieve the thumbnail URL with a specific conversion:

```php theme={null}
$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:**

```php theme={null}
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):

```php theme={null}
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:**

```php theme={null}
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:

```php theme={null}
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:

```php theme={null}
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:

```php theme={null}
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.

```php theme={null}
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()`:

```php theme={null}
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:

```php theme={null}
$variants = $product->variants()->orderBy('position')->get();
```

To find a variant by its SKU:

```php theme={null}
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):

```php theme={null}
$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):

```php theme={null}
$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:

```php theme={null}
$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.

```php theme={null}
$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:

```bash theme={null}
php artisan shopper:component:publish product
```

Variant-related components in `config/shopper/components/product.php`:

```php theme={null}
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.

```php theme={null}
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'),
        ]);
    }
}
```
