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

# Products

> Define, categorize, and manage products with variants, pricing, options, and associations.

Products are the core catalog model in Lunar, representing items available for sale with variants, pricing, and options.

## Overview

Products represent the items available for sale in a store. All custom attributes are defined against the product, and products can have multiple variations. In Lunar, a product always has at least one variant. When a product has only a single variant, the editing experience appears as though the product itself is being edited directly, but behind the scenes the data lives on the variant.

Every product belongs to a `ProductType`, which determines which attributes are available during editing. Products can also optionally belong to a `Brand`.

```php theme={null}
Lunar\Models\Product
```

### Fields

| Field | Type | Description |
| :- | :- | :- |
| `id` | `bigIncrements` | Primary key |
| `brand_id` | `foreignId` `nullable` | Optional brand association |
| `product_type_id` | `foreignId` | The product type |
| `status` | `string` | Product status (e.g. `published`, `draft`) |
| `attribute_data` | `json` `nullable` | Custom attribute data |
| `created_at` | `timestamp` | |
| `updated_at` | `timestamp` | |
| `deleted_at` | `timestamp` `nullable` | Soft deletes |

### Relationships

| Relationship | Type | Related Model | Description |
| :- | :- | :- | :- |
| `productType` | BelongsTo | `Lunar\Models\ProductType` | The product's type |
| `brand` | BelongsTo | `Lunar\Models\Brand` | The product's brand |
| `variants` | HasMany | `Lunar\Models\ProductVariant` | All variants of the product |
| `variant` | HasOne | `Lunar\Models\ProductVariant` | Single variant convenience accessor |
| `prices` | HasManyThrough | `Lunar\Models\Price` | All prices across variants |
| `collections` | BelongsToMany | `Lunar\Models\Collection` | Pivot: `position` |
| `associations` | HasMany | `Lunar\Models\ProductAssociation` | Outgoing product associations |
| `inverseAssociations` | HasMany | `Lunar\Models\ProductAssociation` | Incoming product associations |
| `customerGroups` | BelongsToMany | `Lunar\Models\CustomerGroup` | Pivot: `purchasable`, `visible`, `enabled`, `starts_at`, `ends_at` |
| `channels` | MorphToMany | `Lunar\Models\Channel` | Pivot: `enabled`, `starts_at`, `ends_at` |
| `productOptions` | BelongsToMany | `Lunar\Models\ProductOption` | Pivot: `position` |
| `urls` | MorphMany | `Lunar\Models\Url` | SEO-friendly URLs |
| `tags` | MorphToMany | `Lunar\Models\Tag` | |
| `images` | MorphMany | Media | Product images |
| `thumbnail` | MorphOne | Media | Primary thumbnail image |

### Scopes

| Scope | Description |
| :- | :- |
| `status($status)` | Filter by product status |
| `channel($channel, $startsAt, $endsAt)` | Filter by channel availability |
| `customerGroup($group, $startsAt, $endsAt)` | Filter by customer group visibility |

## Creating a Product

```php theme={null}
use Lunar\Models\Product;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;

Product::create([
    'product_type_id' => $productType->id,
    'status' => 'published',
    'brand_id' => $brand->id,
    'attribute_data' => [
        'name' => new TranslatedText(collect([
            'en' => new Text('FooBar'),
        ])),
        'description' => new Text('This is a Foobar product.'),
    ],
]);
```

<Warning>
  Lunar internally expects and uses the `name` attribute in product attribute data. It must be included in the product type's attributes and populated; otherwise the admin panel may throw unexpected errors.
</Warning>

### Filtering by status

```php theme={null}
use Lunar\Models\Product;

Product::status('published')->get();
```

## Channels

Products support multi-channel availability through the `HasChannels` trait. When a product is created, all channels are automatically synced. Each channel can be independently enabled or disabled, with optional start and end dates for scheduled availability.

### Scheduling a channel

```php theme={null}
// Enable for a channel immediately
$product->scheduleChannel($channel);

// Schedule availability to start in 14 days
$product->scheduleChannel($channel, now()->addDays(14));

// Accepts a collection of channels
$product->scheduleChannel(Channel::get());
```

### Filtering by channel

```php theme={null}
use Lunar\Models\Product;

$products = Product::channel($channel)->get();
```

## Customer Groups

Products can be assigned to customer groups with optional scheduling. This controls whether the product is visible and purchasable for members of each group.

### Scheduling a customer group

```php theme={null}
// Enable for this customer group immediately
$product->scheduleCustomerGroup($customerGroup);

// Schedule the product to be enabled in 14 days for this customer group
$product->scheduleCustomerGroup($customerGroup, now()->addDays(14));

// Accepts an array or collection of customer groups
$product->scheduleCustomerGroup(CustomerGroup::get());
```

### Filtering by customer group

The `customerGroup` scope accepts a single customer group (or ID), or a collection/array of customer groups or IDs.

```php theme={null}
use Lunar\Models\Product;

$products = Product::customerGroup(CustomerGroup::find(1))->paginate(50);

$products = Product::customerGroup([
    $groupA,
    $groupB,
])->paginate(50);
```

## Product Types

Product types categorize products and determine which attributes are available during editing (e.g. Television, T-Shirt, Book, Phone).

```php theme={null}
Lunar\Models\ProductType
```

### Fields

| Field | Type | Description |
| :- | :- | :- |
| `id` | `bigIncrements` | Primary key |
| `name` | `string` | The product type name |
| `created_at` | `timestamp` | |
| `updated_at` | `timestamp` | |

### Relationships

| Relationship | Type | Related Model | Description |
| :- | :- | :- | :- |
| `products` | HasMany | `Lunar\Models\Product` | All products of this type |
| `mappedAttributes` | MorphToMany | `Lunar\Models\Attribute` | All attributes mapped to this type |
| `productAttributes` | MorphToMany | `Lunar\Models\Attribute` | Attributes for products |
| `variantAttributes` | MorphToMany | `Lunar\Models\Attribute` | Attributes for variants |

### Creating a product type

```php theme={null}
use Lunar\Models\ProductType;

$productType = ProductType::create([
    'name' => 'Boots',
]);
```

Product types have [Attributes](/1.x/reference/attributes) associated to them. These associated attributes determine which fields are available to products when editing. For example, an attribute of `Screen Type` associated to a `TVs` product type would make that field available on any product with that type.

Attributes can be associated using a standard [polymorphic relationship](https://laravel.com/docs/eloquent-relationships#many-to-many-polymorphic-relations):

```php theme={null}
$productType->mappedAttributes()->attach([/* attribute ids ... */]);
```

Both `Product` and `ProductVariant` attributes can be associated to a product type, and each will display on the corresponding model when editing.

<Warning>
  Deleting an attribute will drop the association and could result in data loss.
</Warning>

### Retrieving the product type relationship

```php theme={null}
$product->productType;

$product->load(['productType']);
```

## Product Options

Product options define the different variations available for a product. Each `ProductOption` has a set of `ProductOptionValue` models. For example, a `ProductOption` called "Color" could have values like "Blue", "Red", and "Green".

<Tip>
  Product options and product option values are defined at a system level and are translatable.
</Tip>

### Creating a ProductOption

```php theme={null}
use Lunar\Models\ProductOption;

$option = ProductOption::create([
    'name' => [
        'en' => 'Color',
        'fr' => 'Couleur',
    ],
    'label' => [
        'en' => 'Color',
        'fr' => 'Couleur',
    ],
]);
```

Values can then be created for the option:

```php theme={null}
// Lunar\Models\ProductOptionValue
$option->values()->createMany([
    [
        'name' => [
            'en' => 'Blue',
            'fr' => 'Bleu',
        ],
    ],
    [
        'name' => [
            'en' => 'Red',
            'fr' => 'Rouge',
        ],
    ],
]);
```

This product option and its values are now ready to be used with product variants.

### Product Option Meta

Both `ProductOption` and `ProductOptionValue` models include a `meta` field for storing custom information such as color hex values, image links, or other display data.

Lunar makes no assumptions about the structure of the `meta` JSON field. Any values can be stored in whatever format the application requires.

## Product Associations

Products can be associated with other products as cross-sells, up-sells, or alternates. See the [Associations](/1.x/reference/associations) reference for full details on creating and managing product associations.

## Variants

Variants represent the different purchasable permutations of a product, such as "Small Blue T-shirt" or "Size 9 Leather Boots". The product acts as the parent, and variants hold the specific data including pricing, inventory, shipping information, and product identifiers.

A product always has at least one variant. When additional variants are generated, Lunar uses the first variant as a baseline for pricing, inventory, and other data.

```php theme={null}
Lunar\Models\ProductVariant
```

### Fields

| Field | Type | Description |
| :- | :- | :- |
| `id` | `bigIncrements` | Primary key |
| `product_id` | `foreignId` | The parent product |
| `tax_class_id` | `foreignId` | Tax classification |
| `tax_ref` | `string` `nullable` | Tax reference identifier |
| `unit_quantity` | `integer` | Units per single purchase, default: `1` |
| `min_quantity` | `integer` | Minimum purchasable quantity, default: `1` |
| `quantity_increment` | `integer` | Purchase quantity step, default: `1` |
| `sku` | `string` `nullable` | Stock keeping unit |
| `gtin` | `string` `nullable` | Global Trade Item Number |
| `mpn` | `string` `nullable` | Manufacturer Part Number |
| `ean` | `string` `nullable` | European Article Number |
| `length_value` | `decimal(10,4)` `nullable` | Length dimension |
| `length_unit` | `string` `nullable` | Length unit of measure |
| `width_value` | `decimal(10,4)` `nullable` | Width dimension |
| `width_unit` | `string` `nullable` | Width unit of measure |
| `height_value` | `decimal(10,4)` `nullable` | Height dimension |
| `height_unit` | `string` `nullable` | Height unit of measure |
| `weight_value` | `decimal(10,4)` `nullable` | Weight value |
| `weight_unit` | `string` `nullable` | Weight unit of measure |
| `volume_value` | `decimal(10,4)` `nullable` | Volume value |
| `volume_unit` | `string` `nullable` | Volume unit of measure |
| `shippable` | `boolean` | Whether the variant requires shipping, default: `true` |
| `stock` | `integer` | Current stock quantity, default: `0` |
| `backorder` | `integer` | Backorder quantity, default: `0` |
| `purchasable` | `string` | Purchasability mode: `always` or `in_stock`, default: `always` |
| `attribute_data` | `json` `nullable` | Custom attribute data |
| `created_at` | `timestamp` | |
| `updated_at` | `timestamp` | |
| `deleted_at` | `timestamp` `nullable` | Soft deletes |

### Product Identifiers

Each variant can store product identifiers for use in internal systems or external services.

**SKU** (Stock Keeping Unit) — A code (usually eight alphanumeric digits) used to track stock levels internally. Each variant of a product typically has a unique SKU.

**GTIN** (Global Trade Item Number) — An internationally recognized product identifier, often accompanying a barcode. Useful with services like Google Shopping to help classify products.

**MPN** (Manufacturer Part Number) — An identifier from the manufacturer that differentiates a product among similar items from the same brand.

**EAN** (European Article Number) — A series of characters that identifies specific products within an inventory system.

### Creating Variants

A product variant requires a product, currency (for pricing), and a tax class.

```php theme={null}
use Lunar\Models\Product;
use Lunar\Models\ProductVariant;
use Lunar\Models\TaxClass;
use Lunar\Models\Currency;

$product = Product::where(...)->first();
$taxClass = TaxClass::where(...)->first();
$currency = Currency::where(...)->first();
```

Create the product option and its values:

```php theme={null}
use Lunar\Models\ProductOption;

$option = ProductOption::create([
    'name' => [
        'en' => 'Color',
    ],
    'label' => [
        'en' => 'Color',
    ],
]);

$blueOption = $option->values()->create([
    'name' => [
        'en' => 'Blue',
    ],
]);

$redOption = $option->values()->create([
    'name' => [
        'en' => 'Red',
    ],
]);
```

Create the variants and attach their option values:

```php theme={null}
$blueVariant = ProductVariant::create([
    'product_id' => $product->id,
    'tax_class_id' => $taxClass->id,
    'sku' => 'blue-product',
]);

$blueVariant->values()->attach($blueOption);

$redVariant = ProductVariant::create([
    'product_id' => $product->id,
    'tax_class_id' => $taxClass->id,
    'sku' => 'red-product',
]);

$redVariant->values()->attach($redOption);
```

Then create pricing for each variant:

```php theme={null}
$blueVariant->prices()->create([
    'price' => 199,
    'currency_id' => $currency->id,
]);

$redVariant->prices()->create([
    'price' => 199,
    'currency_id' => $currency->id,
]);
```

### Exceptions

| Exception | Conditions |
| :- | :- |
| `Illuminate\Validation\ValidationException` | Thrown if validation fails on the value options array |

## Shipping

By default, all product variants are marked as shippable. To mark a variant as non-shippable:

```php theme={null}
$variant->update([
    'shippable' => false,
]);
```

### Dimensions

Products can store dimension data on each variant. The available dimensions are:

* Length
* Width
* Height
* Weight
* Volume

For handling unit conversions, Lunar uses the [Cartalyst Converter](https://github.com/cartalyst/converter) package, which supports a wide range of units of measure.

Each dimension has a corresponding `_value` and `_unit` column in the database:

```php theme={null}
$variant->length_value;
$variant->length_unit;
$variant->width_value;
$variant->width_unit;
// etc.
```

### Configuring measurements

Available units of measure can be configured in the `lunar/shipping.php` config file. The defaults include:

**Length:** m, mm, cm, ft, in

**Weight:** kg, g, lbs

**Volume:** l, ml, gal, floz

### Getting and converting measurement values

The raw `*_value` and `*_unit` values can be accessed directly, but Lunar also provides an accessor for each dimension that supports conversion:

```php theme={null}
$variant->length->to('length.ft')->convert();
```

#### Volume calculation

Volume can be calculated automatically from the length, width, and height dimensions, or set manually:

```php theme={null}
$variant->update([
    'length_value' => 50,
    'length_unit' => 'mm',
    'height_value' => 50,
    'height_unit' => 'mm',
    'width_value' => 50,
    'width_unit' => 'mm',
]);

// Returns ml by default
$variant->volume->getValue(); // 125.0

// Convert to any supported volume unit
$variant->volume->to('volume.l')->convert()->getValue(); // 0.125

// Setting a manual volume overrides the automatic calculation
$variant->update([
    'volume_unit' => 'floz',
    'volume_value' => 100,
]);

$variant->volume->getValue(); // 100

$variant->volume->to('volume.l')->convert()->getValue(); // 2.95735...
```

**Formatted values**

```php theme={null}
$variant->length->to('length.cm')->convert()->format(); // 50cm
```

## Pricing

### Overview

Prices are stored in the database as integers. When retrieving a `Price` model, the `price` and `compare_price` attributes are cast to a `Price` datatype with useful helpers for display.

| Field | Description | Default | Required |
| :- | :- | :- | :- |
| `price` | An integer value for the price | `null` | yes |
| `compare_price` | A comparison price for display purposes (e.g. RRP) | `null` | no |
| `currency_id` | The ID of the related currency | `null` | yes |
| `min_quantity` | The minimum quantity required to get this price | `1` | no |
| `customer_group_id` | The customer group this price applies to; `null` means all groups | `null` | no |
| `priceable_type` | The class reference to the related model that owns the price | `null` | yes |
| `priceable_id` | The ID of the related model that owns the price | `null` | yes |

```php theme={null}
use Lunar\Models\ProductVariant;
use Lunar\Models\Price;

$priceable = ProductVariant::create([/* ... */]);

Price::create([
    'price' => 199,
    'compare_price' => 299,
    'currency_id' => 1,
    'min_quantity' => 1,
    'customer_group_id' => null,
    'priceable_type' => $priceable->getMorphClass(),
    'priceable_id' => $priceable->id,
]);
```

For full details on price formatting, see the [Pricing Reference](/1.x/reference/pricing).

<Tip>
  The same formatting methods apply to the `compare_price` attribute.
</Tip>

### Base Pricing

Pricing is defined at the variant level, meaning each variant has its own price for each currency. Prices can be created directly or through the relationship:

```php theme={null}
use Lunar\Models\ProductVariant;
use Lunar\Models\Price;

$priceable = ProductVariant::create([/* ... */]);

Price::create([
    'price' => 199,
    'compare_price' => 299,
    'currency_id' => 1,
    'min_quantity' => 1,
    'customer_group_id' => null,
    'priceable_type' => $priceable->getMorphClass(),
    'priceable_id' => $priceable->id,
]);

// Or via the relationship
$variant->prices()->create([/* ... */]);
```

### Customer Group Pricing

Setting the `customer_group_id` column controls which customer group a price applies to. When left as `null`, the price applies to all customer groups. This allows different pricing and quantity breaks per customer group.

### Price Breaks

Price breaks adjust the unit price based on purchase quantity. The `min_quantity` column determines when each price tier applies:

```php theme={null}
use Lunar\Models\Price;

Price::create([
    // ...
    'price' => 199,
    'compare_price' => 399,
    'min_quantity' => 1,
]);

Price::create([
    // ...
    'price' => 150,
    'compare_price' => 399,
    'min_quantity' => 10,
]);
```

In the above example, ordering 1–9 items costs 1.99 per item, while ordering 10 or more costs 1.50 per item.

### Fetching Prices

The `PricingManager` facade provides a fluent API for retrieving the correct price based on various criteria.

#### Minimum example

A quantity of 1 is implied when not passed.

```php theme={null}
$pricing = \Lunar\Facades\Pricing::for($variant)->get();
```

#### With quantities

```php theme={null}
$pricing = \Lunar\Facades\Pricing::qty(5)->for($variant)->get();
```

#### With customer groups

If no customer group is passed, Lunar uses the default group and includes pricing that is not specific to any group.

```php theme={null}
$pricing = \Lunar\Facades\Pricing::customerGroups($groups)->for($variant)->get();

// Or a single customer group
$pricing = \Lunar\Facades\Pricing::customerGroup($group)->for($variant)->get();
```

#### For a specific user

The `PricingManager` assumes the current authenticated user by default.

```php theme={null}
// Always return the guest price
$pricing = \Lunar\Facades\Pricing::guest()->for($variant)->get();

// Specify a different user
$pricing = \Lunar\Facades\Pricing::user($user)->for($variant)->get();
```

#### With a specific currency

If no currency is passed, the default currency is used.

```php theme={null}
$pricing = \Lunar\Facades\Pricing::currency($currency)->for($variant)->get();
```

#### From a model

Any model using the `HasPrices` trait (such as `ProductVariant`) exposes a `pricing()` method:

```php theme={null}
$pricing = $variant->pricing()->qty(5)->get();
```

<Warning>
  Fetching a price for a currency that has no pricing defined will throw a `Lunar\Exceptions\MissingCurrencyPriceException`.
</Warning>

***

The `get()` method returns a `PricingResponse` object. Unless noted as a collection, each property returns a `Lunar\Models\Price` object.

```php theme={null}
// The price that matched the given criteria
$pricing->matched;

// The base price associated with the variant
$pricing->base;

// A collection of all price quantity breaks for the given criteria
$pricing->priceBreaks;

// All customer group pricing for the given criteria
$pricing->customerGroupPrices;
```

To retrieve all prices across a product's variants without loading the variants individually, use the `prices` relationship on the product:

```php theme={null}
$product->prices;
```

### Storing Prices Inclusive of Tax

Lunar supports storing pricing inclusive of tax, which is useful for charm pricing (e.g. \$9.99) that may not be achievable when storing prices exclusive of tax due to rounding.

To enable this, set the `stored_inclusive_of_tax` config value in `lunar/pricing` to `true` and ensure the default tax zone is configured with the correct tax rates. The cart will then automatically calculate tax correctly.

To display both tax-inclusive and tax-exclusive prices on product pages:

```php theme={null}
$price->priceIncTax();
$price->priceExTax();
$price->comparePriceIncTax();
```

### Customizing Prices with Pipelines

Pricing pipelines are defined in `config/lunar/pricing.php`:

```php theme={null}
'pipelines' => [
    //
],
```

Custom pipelines can modify pricing during resolution:

```php theme={null}
<?php

namespace App\Pipelines\Pricing;

use Closure;
use Lunar\Base\PricingManagerInterface;

class CustomPricingPipeline
{
    public function handle(PricingManagerInterface $pricingManager, Closure $next)
    {
        $matchedPrice = $pricingManager->pricing->matched;

        $matchedPrice->price->value = 200;

        $pricingManager->pricing->matched = $matchedPrice;

        return $next($pricingManager);
    }
}
```

```php theme={null}
'pipelines' => [
    // ...
    App\Pipelines\Pricing\CustomPricingPipeline::class,
],
```

<Tip>
  Pipelines run from top to bottom.
</Tip>

## Full Example

This example walks through creating a pair of Dr. Martens boots with multiple size and color variants.

<img src="https://mintcdn.com/lunar-b8371d04/qFVwD7q9SLAj41nj/1.x/images/products/dr-martens.png?fit=max&auto=format&n=qFVwD7q9SLAj41nj&q=85&s=e89fa455547d9ae5b586c934e741d51f" alt="" width="1244" height="742" data-path="1.x/images/products/dr-martens.png" />

The steps involved are:

* Create the product type
* Create the initial product
* Create product options and their values
* Create the variants

### Set up the product type

```php theme={null}
use Lunar\Models\ProductType;

$productType = ProductType::create([
    'name' => 'Boots',
]);
```

<Note>
  This example assumes attributes for name and description already exist and are assigned to the product type.
</Note>

### Create the initial product

```php theme={null}
use Lunar\Models\Product;
use Lunar\FieldTypes\Text;
use Lunar\FieldTypes\TranslatedText;

$product = Product::create([
    'product_type_id' => $productType->id,
    'status' => 'published',
    'brand_id' => $brandId,
    'attribute_data' => [
        'name' => new TranslatedText(collect([
            'en' => new Text('1460 PATENT LEATHER BOOTS'),
        ])),
        'description' => new Text('Even more shades from the archive...'),
    ],
]);
```

### Create product options

Based on the example above, two options are needed: Size and Color.

```php theme={null}
use Lunar\Models\ProductOption;

$color = ProductOption::create([
    'name' => [
        'en' => 'Color',
    ],
    'label' => [
        'en' => 'Color',
    ],
]);

$size = ProductOption::create([
    'name' => [
        'en' => 'Size',
    ],
    'label' => [
        'en' => 'Size',
    ],
]);
```

### Create product option values

```php theme={null}
$color->values()->createMany([
    [
        'name' => [
            'en' => 'Black',
        ],
    ],
    [
        'name' => [
            'en' => 'White',
        ],
    ],
    [
        'name' => [
            'en' => 'Pale Pink',
        ],
    ],
    [
        'name' => [
            'en' => 'Mid Blue',
        ],
    ],
]);

$size->values()->createMany([
    [
        'name' => [
            'en' => '3',
        ],
    ],
    [
        'name' => [
            'en' => '6',
        ],
    ],
]);
```

### Create the variants

With the options and values defined, variants can be created for each combination. Each variant needs a product, tax class, SKU, and at least one price.

```php theme={null}
use Lunar\Models\ProductVariant;
use Lunar\Models\TaxClass;
use Lunar\Models\Currency;

$taxClass = TaxClass::first();
$currency = Currency::first();
$count = 0;

foreach ($color->values as $colorValue) {
    foreach ($size->values as $sizeValue) {
        $count++;

        $variant = ProductVariant::create([
            'product_id' => $product->id,
            'tax_class_id' => $taxClass->id,
            'sku' => "DRBOOT-{$count}",
        ]);

        $variant->values()->attach([$colorValue->id, $sizeValue->id]);

        $variant->prices()->create([
            'price' => 16900,
            'currency_id' => $currency->id,
        ]);
    }
}
```

The resulting variants:

| SKU | Color | Size |
| :- | :- | :- |
| DRBOOT-1 | Black | 3 |
| DRBOOT-2 | Black | 6 |
| DRBOOT-3 | White | 3 |
| DRBOOT-4 | White | 6 |
| DRBOOT-5 | Pale Pink | 3 |
| DRBOOT-6 | Pale Pink | 6 |
| DRBOOT-7 | Mid Blue | 3 |
| DRBOOT-8 | Mid Blue | 6 |

SKUs, pricing, and other variant details can be adjusted as needed before publishing.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.