Skip to main content
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.
Lunar\Models\Product

Fields

FieldTypeDescription
idbigIncrementsPrimary key
brand_idforeignId nullableOptional brand association
product_type_idforeignIdThe product type
statusstringProduct status (e.g. published, draft)
attribute_datajson nullableCustom attribute data
created_attimestamp
updated_attimestamp
deleted_attimestamp nullableSoft deletes

Relationships

RelationshipTypeRelated ModelDescription
productTypeBelongsToLunar\Models\ProductTypeThe product’s type
brandBelongsToLunar\Models\BrandThe product’s brand
variantsHasManyLunar\Models\ProductVariantAll variants of the product
variantHasOneLunar\Models\ProductVariantSingle variant convenience accessor
pricesHasManyThroughLunar\Models\PriceAll prices across variants
collectionsBelongsToManyLunar\Models\CollectionPivot: position
associationsHasManyLunar\Models\ProductAssociationOutgoing product associations
inverseAssociationsHasManyLunar\Models\ProductAssociationIncoming product associations
customerGroupsBelongsToManyLunar\Models\CustomerGroupPivot: purchasable, visible, enabled, starts_at, ends_at
channelsMorphToManyLunar\Models\ChannelPivot: enabled, starts_at, ends_at
productOptionsBelongsToManyLunar\Models\ProductOptionPivot: position
urlsMorphManyLunar\Models\UrlSEO-friendly URLs
tagsMorphToManyLunar\Models\Tag
imagesMorphManyMediaProduct images
thumbnailMorphOneMediaPrimary thumbnail image

Scopes

ScopeDescription
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

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

Filtering by status

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

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

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

// 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.
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).
Lunar\Models\ProductType

Fields

FieldTypeDescription
idbigIncrementsPrimary key
namestringThe product type name
created_attimestamp
updated_attimestamp

Relationships

RelationshipTypeRelated ModelDescription
productsHasManyLunar\Models\ProductAll products of this type
mappedAttributesMorphToManyLunar\Models\AttributeAll attributes mapped to this type
productAttributesMorphToManyLunar\Models\AttributeAttributes for products
variantAttributesMorphToManyLunar\Models\AttributeAttributes for variants

Creating a product type

use Lunar\Models\ProductType;

$productType = ProductType::create([
    'name' => 'Boots',
]);
Product types have 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:
$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.
Deleting an attribute will drop the association and could result in data loss.

Retrieving the product type relationship

$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”.
Product options and product option values are defined at a system level and are translatable.

Creating a ProductOption

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:
// 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 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.
Lunar\Models\ProductVariant

Fields

FieldTypeDescription
idbigIncrementsPrimary key
product_idforeignIdThe parent product
tax_class_idforeignIdTax classification
tax_refstring nullableTax reference identifier
unit_quantityintegerUnits per single purchase, default: 1
min_quantityintegerMinimum purchasable quantity, default: 1
quantity_incrementintegerPurchase quantity step, default: 1
skustring nullableStock keeping unit
gtinstring nullableGlobal Trade Item Number
mpnstring nullableManufacturer Part Number
eanstring nullableEuropean Article Number
length_valuedecimal(10,4) nullableLength dimension
length_unitstring nullableLength unit of measure
width_valuedecimal(10,4) nullableWidth dimension
width_unitstring nullableWidth unit of measure
height_valuedecimal(10,4) nullableHeight dimension
height_unitstring nullableHeight unit of measure
weight_valuedecimal(10,4) nullableWeight value
weight_unitstring nullableWeight unit of measure
volume_valuedecimal(10,4) nullableVolume value
volume_unitstring nullableVolume unit of measure
shippablebooleanWhether the variant requires shipping, default: true
stockintegerCurrent stock quantity, default: 0
backorderintegerBackorder quantity, default: 0
purchasablestringPurchasability mode: always or in_stock, default: always
attribute_datajson nullableCustom attribute data
created_attimestamp
updated_attimestamp
deleted_attimestamp nullableSoft 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.
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:
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:
$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:
$blueVariant->prices()->create([
    'price' => 199,
    'currency_id' => $currency->id,
]);

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

Exceptions

ExceptionConditions
Illuminate\Validation\ValidationExceptionThrown 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:
$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 package, which supports a wide range of units of measure. Each dimension has a corresponding _value and _unit column in the database:
$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:
$variant->length->to('length.ft')->convert();

Volume calculation

Volume can be calculated automatically from the length, width, and height dimensions, or set manually:
$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
$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.
FieldDescriptionDefaultRequired
priceAn integer value for the pricenullyes
compare_priceA comparison price for display purposes (e.g. RRP)nullno
currency_idThe ID of the related currencynullyes
min_quantityThe minimum quantity required to get this price1no
customer_group_idThe customer group this price applies to; null means all groupsnullno
priceable_typeThe class reference to the related model that owns the pricenullyes
priceable_idThe ID of the related model that owns the pricenullyes
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.
The same formatting methods apply to the compare_price attribute.

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:
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:
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.
$pricing = \Lunar\Facades\Pricing::for($variant)->get();

With quantities

$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.
$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.
// 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.
$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:
$pricing = $variant->pricing()->qty(5)->get();
Fetching a price for a currency that has no pricing defined will throw a Lunar\Exceptions\MissingCurrencyPriceException.

The get() method returns a PricingResponse object. Unless noted as a collection, each property returns a Lunar\Models\Price object.
// 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:
$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:
$price->priceIncTax();
$price->priceExTax();
$price->comparePriceIncTax();

Customizing Prices with Pipelines

Pricing pipelines are defined in config/lunar/pricing.php:
'pipelines' => [
    //
],
Custom pipelines can modify pricing during resolution:
<?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);
    }
}
'pipelines' => [
    // ...
    App\Pipelines\Pricing\CustomPricingPipeline::class,
],
Pipelines run from top to bottom.

Full Example

This example walks through creating a pair of Dr. Martens boots with multiple size and color variants. 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

use Lunar\Models\ProductType;

$productType = ProductType::create([
    'name' => 'Boots',
]);
This example assumes attributes for name and description already exist and are assigned to the product type.

Create the initial product

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

$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.
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:
SKUColorSize
DRBOOT-1Black3
DRBOOT-2Black6
DRBOOT-3White3
DRBOOT-4White6
DRBOOT-5Pale Pink3
DRBOOT-6Pale Pink6
DRBOOT-7Mid Blue3
DRBOOT-8Mid Blue6
SKUs, pricing, and other variant details can be adjusted as needed before publishing.