Skip to content

Product Models

This reference documents all Twig-accessible properties and methods for product-related models. All prices are stored as integers in base currency units (cents) — use the |currency filter for display.

Product

The Product model is the central model for all product data. It is typically accessed via the Catalog component as product or through category/group relationships.

Core Properties

PropertyTypeDescription
idintPrimary key
namestringProduct name
slugstringURL slug
baseidstringBase identifier for URL generation
skustringSKU code
titlestringSEO/page title
descriptionstringFull description (HTML)
short_descriptionstringShort description
is_enabledboolWhether the product is active
created_atCarbonCreation date
updated_atCarbonLast update date
available_atCarbonAvailability date (for pre-orders)

Price Properties

PropertyTypeDescription
priceintRaw base price (no tax, no rules)
costintCost/wholesale price
is_on_saleboolWhether a manual sale price is set
sale_priceintManual sale price (in base currency units)
display_priceintBest display price with tax (sale or catalog rule applied)
compare_priceintBase display price with tax (for strikethrough comparison)
on_saleboolWhether the product has a price reduction (manual or catalog rule)
display_discountintAmount saved: compare_price - display_price
in_stockboolWhether the product is available for purchase
has_variantsboolWhether the product uses variants

TIP

Use display_price for storefront display and compare_price for strikethrough pricing — both automatically apply tax display settings.

twig
{{ product.display_price|currency }}

{% if product.on_sale %}
    <del>{{ product.compare_price|currency }}</del>
    <ins>{{ product.display_price|currency }}</ins>
    <span>Save {{ product.display_discount|currency }}</span>
{% endif %}

Physical Properties

PropertyTypeDescription
weightfloatWeight
widthfloatWidth
heightfloatHeight
depthfloatDepth
volumefloatComputed: width × height × depth

Inventory Properties

PropertyTypeDescription
track_inventoryboolWhether stock is tracked
hide_if_out_of_stockboolHide product when out of stock
allow_negative_stockboolAllow stock to go below zero
stock_alert_thresholdintLow stock notification threshold
units_in_stockint|nullPhysical units on hand
units_reservedintUnits held by pending orders
allow_pre_orderboolAccept orders when out of stock

Use getSalableQuantity() and isOutOfStock() to check availability. See Inventory for the stock lifecycle.

Visibility Properties

PropertyTypeDescription
is_visible_searchboolShow in search results
is_visible_catalogboolShow in catalog listings
is_visible_user_groupboolRestrict visibility to specific user groups
is_visible_siteboolRestrict visibility to specific sites
custom_pagestring|nullCustom CMS page override (pagefinder reference)

Review Properties

PropertyTypeDescription
reviews_ratingfloatCached average rating (0–5)
reviews_countintCached total approved reviews

Variant Properties

PropertyTypeDescription
use_variantsboolWhether variants are enabled
variant_generationstringGeneration mode
allow_price_tiersboolWhether tier pricing is enabled

Relationships

PropertyTypeDescription
categoriesCollection<Category>Associated categories
related_productsCollection<Product>Related products
manufacturerManufacturerManufacturer/brand
product_typeProductTypeProduct type (controls feature flags)
tax_classTaxClassTax class
imagesCollection<File>Product images
filesCollection<File>Downloadable files
optionsCollection<ProductOption>Configurable options (Size, Color)
extrasCollection<ProductExtra>Local extras (product-specific add-ons)
all_extrasCollection<ProductExtra>Combined local extras + extra set extras
propertiesCollection<ProductProperty>Specifications (Material, Weight)
price_tiersCollection<PriceTier>Volume pricing tiers
visible_price_tiersCollection<PriceTier>Tiers filtered for the current user's group (falls back to generic tiers)
variantsCollection<ProductVariant>Product variants
bundle_itemsCollection<ProductBundleItem>Bundle slots
reviewsCollection<ProductReview>Product reviews
custom_groupsCollection<CustomGroup>Custom product groups
user_groupsCollection<UserGroup>Visible-to user groups
site_definitionsCollection<SiteDefinition>Visible-on sites (when is_visible_site is enabled)
extra_setsCollection<ProductExtraSet>Assigned extra option sets

Methods

MethodReturnsDescription
pageUrl($pageName)stringCMS page URL for the product (respects custom_page override)
getBreadcrumbPath()array|nullParent category chain for breadcrumbs
getPrimaryCategory()Category|nullFirst associated category
isVisible()boolWhether product is enabled and not archived
isVisibleOnSite($siteId)boolWhether product is visible on a specific site (defaults to current site)
isOutOfStock()boolWhether stock is below threshold
getSalableQuantity($siteId)intAvailable stock (physical minus reserved)
reserveStock($quantity)voidAtomically increment reserved units
decreaseStock($quantity)voidDecrement physical stock and release reservation
releaseStock($quantity)voidRelease reservation without changing physical stock
getOriginalPrice($qty, $groupId)intBase price with tier pricing
getOriginalSalePrice($qty, $groupId)intSale price (manual or catalog rules)
getFinalPrice($qty, $groupId)intDisplay price with tax
getFinalSalePrice($qty, $groupId)intSale price with tax
getSalePriceReduction($qty, $groupId)intDiscount amount
evalTierPrice($qty, $groupId)intEvaluate tier price for quantity
getCompiledRulePrice($qty, $groupId)int|nullCatalog rule price (null if none)
getAverageRating()floatCached average review rating
getReviewsCount()intCached approved review count
resolveVariant($options)ProductVariant|nullFind variant matching options (throws on mismatch)
resolveVariantSafe($options)ProductVariant|nullFind variant matching options (returns null on mismatch)

Scopes

ScopeDescription
applyVisibleFilters out disabled and archived products
applySiteVisibilityFilters by per-site visibility for the active site
listFrontEnd($options)Paginated listing with filtering, sorting, search, and visibility

listFrontEnd

The listFrontEnd scope is the primary entry point for querying products on the frontend. It automatically applies visibility and site filtering, and returns a paginated result.

php
Product::listFrontEnd([
    'category' => $categoryId,
    'search' => 'blue widget',
    'sort' => 'price asc',
    'perPage' => 12,
])
OptionTypeDefaultDescription
pageint1Page number
perPageint30Products per page
sortstringcreated_at descSort field with direction (e.g., name asc, price desc, random)
searchstring''Search query
categoriesarray|nullnullArray of category IDs to filter by
categoryint|nullnullSingle category ID (includes child categories)
manufacturersarray|nullnullArray of manufacturer IDs
ratingsarray|nullnullStar ratings to filter by (e.g., [4, 5])
priceMinint|nullnullMinimum price in base value (cents)
priceMaxint|nullnullMaximum price in base value (cents)
exceptProductmixednullProduct ID(s) or slug(s) to exclude

The scope automatically applies applyVisible and applySiteVisibility, so callers do not need to add these constraints manually.

TIP

Category::listProducts($options) is a convenience proxy that calls Product::listFrontEnd with the category pre-filled. Both produce the same result.

Complete Example

twig
<div class="product-detail">
    {# Images #}
    {% for image in product.images %}
        <img src="{{ image.path|resize(600, 600) }}" alt="{{ product.name }}" />
    {% endfor %}

    <h1>{{ product.name }}</h1>
    <p class="sku">SKU: {{ product.sku }}</p>

    {# Manufacturer #}
    {% if product.manufacturer %}
        <p>By <a href="#">{{ product.manufacturer.name }}</a></p>
    {% endif %}

    {# Pricing #}
    {% if product.on_sale %}
        <del>{{ product.compare_price|currency }}</del>
        <strong>{{ product.display_price|currency }}</strong>
    {% else %}
        <strong>{{ product.display_price|currency }}</strong>
    {% endif %}

    {# Tier pricing #}
    {% set visibleTiers = product.visible_price_tiers %}
    {% if visibleTiers is not empty %}
        <table class="tier-pricing">
            <tr><th>Quantity</th><th>Price</th></tr>
            <tr>
                <td>1+</td>
                <td>{{ product.display_price|currency }}</td>
            </tr>
            {% for tier in visibleTiers %}
                <tr>
                    <td>{{ tier.quantity_label }}</td>
                    <td>{{ tier.price|currency }}</td>
                </tr>
            {% endfor %}
        </table>
    {% endif %}

    {# Stock #}
    {% if product.track_inventory %}
        {% if product.isOutOfStock() %}
            {% if product.allow_pre_order %}
                <span class="badge">Pre-Order</span>
            {% else %}
                <span class="badge">Out of Stock</span>
            {% endif %}
        {% else %}
            <span>In Stock</span>
        {% endif %}
    {% endif %}

    {# Options #}
    {% for option in product.options %}
        <div class="form-group">
            <label>{{ option.name }}</label>
            <select name="product_options[{{ option.name }}]">
                {% for value in option.values %}
                    <option value="{{ value }}">{{ value }}</option>
                {% endfor %}
            </select>
        </div>
    {% endfor %}

    {# Extras #}
    {% for extra in product.all_extras %}
        <label>
            <input type="checkbox" name="product_extras[{{ extra.id }}]" />
            {{ extra.description }}
            {% if extra.display_price > 0 %}
                (+{{ extra.display_price|currency }})
            {% endif %}
        </label>
    {% endfor %}

    {# Properties / Specifications #}
    {% if product.properties is not empty %}
        <h3>Specifications</h3>
        <dl>
            {% for property in product.properties %}
                <dt>{{ property.name }}</dt>
                <dd>{{ property.value }}</dd>
            {% endfor %}
        </dl>
    {% endif %}

    {# Related products #}
    {% if product.related_products is not empty %}
        <h3>Related Products</h3>
        {% for related in product.related_products %}
            <a href="{{ related.pageUrl() }}">{{ related.name }}</a>
        {% endfor %}
    {% endif %}

    {# Reviews summary #}
    {% if product.reviews_count > 0 %}
        <div>
            {{ product.reviews_rating|number_format(1) }}/5
            ({{ product.reviews_count }} reviews)
        </div>
    {% endif %}

    {# Description #}
    <div class="description">{{ product.description|raw }}</div>
</div>

Custom Product Page

Individual products can override the default product page by setting the custom_page field (a pagefinder reference) in the Visibility tab. When set, pageUrl() automatically resolves to the custom CMS page instead of the default — no theme template changes needed.

The custom CMS page must include the [catalog] component with lookup = "product" and a matching URL pattern. For example:

ini
url = "/shop/product-landing/:slug*/:baseid"

[catalog]
lookup = "product"
identifier = "baseid"

This is useful for flagship products, seasonal landing pages, or any product that needs a unique layout while keeping the same URL parameter structure.

TIP

Theme templates that call product.pageUrl('shop/product') will automatically use the custom page when one is set. The override is transparent — you don't need to check for it in your templates.


ProductOption

Options are selectable attributes like Size or Color. They determine which variant is selected when variants are enabled. Options do not add cost — use extras for priced add-ons.

Properties

PropertyTypeDescription
idintPrimary key
namestringOption name (e.g., "Color", "Size")
valuesarrayAvailable values (e.g., ["Red", "Blue", "Green"])
hashstringMD5 hash of name (used as form field key)
sort_orderintDisplay order
valuestring|nullCurrently selected value (set in cart/order context)

Displaying Options

twig
{# Product page — option selection #}
{% for option in product.options %}
    <div class="form-group">
        <label>{{ option.name }}</label>
        <select name="product_options[{{ option.name }}]">
            {% for value in option.values %}
                <option value="{{ value }}">{{ value }}</option>
            {% endfor %}
        </select>
    </div>
{% endfor %}

{# Cart/order context — selected value #}
{% for option in item.options %}
    <span>{{ option.name }}: {{ option.value }}</span>
{% endfor %}

INFO

When submitting to onAddToCart, options use product_options[OptionName] as the field name. The cart resolves the matching variant automatically.


ProductExtra

Extras are paid or free add-ons that customers can optionally select, such as gift wrapping, extended warranty, or engraving. Unlike options, extras have their own price and can affect weight and dimensions.

Properties

PropertyTypeDescription
idintPrimary key
descriptionstringExtra label/description
group_namestringGroup name for organizing extras
priceintRaw price in cents
display_priceintPrice with tax (if tax display is enabled)
weightfloatAdditional weight
widthfloatWidth
heightfloatHeight
depthfloatDepth
volumefloatComputed: width × height × depth
hashstringMD5 hash of description
sort_orderintDisplay order
imagesCollection<File>Extra images

Local vs Global Extras

  • Local extras (product.extras) — defined directly on a product.
  • Global extras — defined in an Extra Set and assigned to multiple products.
  • All extras (product.all_extras) — merges both into a single collection.

Always use product.all_extras to display extras on the storefront.

Displaying Extras

twig
{% for extra in product.all_extras %}
    <label>
        <input type="checkbox" name="product_extras[{{ extra.id }}]" />
        {% if extra.group_name %}
            <strong>{{ extra.group_name }}:</strong>
        {% endif %}
        {{ extra.description }}
        {% if extra.display_price > 0 %}
            (+{{ extra.display_price|currency }})
        {% else %}
            (Free)
        {% endif %}
    </label>
{% endfor %}

Extras in Cart/Order Context

When retrieved from a cart item or order item, extras have displayPrice properties set from the time of purchase:

twig
{% for extra in item.extras %}
    <span>+ {{ extra.description }}: {{ extra.displayPrice|currency }}</span>
{% endfor %}

ProductProperty

Properties are display-only specifications like Material, Dimensions, or Color. They have no effect on pricing or cart behavior — they are purely informational.

Properties

PropertyTypeDescription
idintPrimary key
namestringProperty name (e.g., "Material")
valuestringProperty value (e.g., "Cotton")
sort_orderintDisplay order

Displaying Properties

twig
{% if product.properties is not empty %}
    <table class="specifications">
        <tbody>
            {% for property in product.properties %}
                <tr>
                    <th>{{ property.name }}</th>
                    <td>{{ property.value }}</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
{% endif %}

ProductVariant

Variants are specific combinations of product options, each with its own SKU, price, stock, and dimensions. When a customer selects options (e.g., Color: Red, Size: Large), the system resolves the matching variant.

Properties

PropertyTypeDescription
idintPrimary key
namestringAuto-generated name (e.g., "Red / Large")
skustring|nullVariant-specific SKU (falls back to product)
priceint|nullOverride price (null uses product price)
costint|nullOverride cost price
compare_priceint|nullCompare-at price
weightfloat|nullOverride weight
widthfloat|nullOverride width
heightfloat|nullOverride height
depthfloat|nullOverride depth
barcodestringBarcode/UPC
is_on_saleboolWhether a manual sale price is set on this variant
sale_priceint|nullManual sale price (in base currency units)
is_enabledboolWhether variant is available
is_defaultboolDefault variant selection

Display Attributes

These attributes mirror Product's API, enabling a unified template interface where item can be either a Product or a ProductVariant.

PropertyTypeDescription
on_saleboolWhether the original and sale prices differ
display_priceintBest price with tax display adjustment
compare_priceintBase price with tax display adjustment
display_discountintAmount saved: compare_price - display_price

TIP

Use display_price and compare_price for storefront display — they automatically apply tax display settings, just like their Product counterparts.

Relationships

PropertyTypeDescription
productProductParent product
variant_optionsCollection<ProductVariantOption>Selected option values
variant_pricesCollection<VariantPrice>Tier/group pricing overrides
imagesCollection<File>Variant-specific images (falls back to product images if empty)

Methods

MethodReturnsDescription
getCompiledPrice($qty, $groupId)intResolved price with tiers, user groups, catalog rules, sale prices, and tax display adjustment
getEffectivePrice()intBase price before sale with tax display adjustment (variant price or product price fallback)
getEffectiveWeight()floatVariant weight or product fallback
getEffectiveWidth()floatVariant width or product fallback
getEffectiveHeight()floatVariant height or product fallback
getEffectiveDepth()floatVariant depth or product fallback
getEffectiveSku()stringVariant SKU or product fallback
getSalableQuantity($siteId)intAvailable stock (physical minus reserved)
isOutOfStock()boolWhether variant is out of stock
reserveStock($quantity)voidAtomically increment reserved units
decreaseStock($quantity)voidDecrement physical stock and release reservation
releaseStock($quantity)voidRelease reservation without changing physical stock

Static Methods

MethodReturnsDescription
computeHash($options)stringGenerate hash from option array
findByOptions($product, $options)ProductVariant|nullFind variant by options

How Variant Resolution Works

When a customer submits product_options with onAddToCart, the cart component calls product.resolveVariant() to find the matching variant:

  1. The option values are normalized and hashed using computeHash().
  2. The hash is looked up in the variants table.
  3. If found and enabled, the variant's price, SKU, and stock are used.
  4. If not found, an exception is thrown.

For template use, resolveVariantSafe() is the preferred method — it returns null instead of throwing when the variant is not found or not available. This is useful when displaying price and availability on the product page while the customer is still selecting options:

twig
{# Resolve variant from posted options (safe — returns null on mismatch) #}
{% set postedOptions = post('product_options', {}) %}
{% set variant = product.resolveVariantSafe(postedOptions) %}

{# Unified price display — works for both products and variants #}
{% set item = variant ?: product %}
<span>{{ item.display_price|currency }}</span>
{% if item.on_sale %}
    <del>{{ item.compare_price|currency }}</del>
    <span>Save {{ item.display_discount|currency }}</span>
{% endif %}

{# Variant-aware images (falls back to product images) #}
{% set images = (variant and variant.images is not empty) ? variant.images : product.images %}

{# Variant availability check #}
{% if variant and not variant.is_enabled %}
    <div class="alert">This combination is currently unavailable.</div>
{% elseif variant and variant.isOutOfStock() %}
    <div class="alert">Out of stock.</div>
{% endif %}

Unified Template API

Product and ProductVariant share the same display attributes (display_price, compare_price, on_sale, display_discount), so you can use {% set item = variant ?: product %} and write pricing markup once. Both include tax display adjustments automatically.

For quantity-aware pricing (e.g., tier pricing), use the methods directly: variant.getCompiledPrice(qty, groupId).

twig
{# Variant info in cart/order context #}
{% if item.variant %}
    <p>{{ item.variant.name }}</p>
    <p>SKU: {{ item.variant.getEffectiveSku() }}</p>
{% endif %}

PriceTier

Price tiers provide volume-based pricing. When a customer adds a quantity that meets or exceeds a tier threshold, the tier price is used instead of the base price.

Properties

PropertyTypeDescription
idintPrimary key
quantityintMinimum quantity for this tier
priceintPrice at this tier (in cents)
quantity_labelstringFormatted label: "X or more"
user_group_labelstringUser group name or "Any User"

Relationships

PropertyTypeDescription
user_groupUserGroup|nullRestrict tier to a user group

Displaying Tier Pricing

Use visible_price_tiers to display tiers appropriate for the current user. This attribute automatically shows user-group-specific tiers when available, falling back to generic tiers otherwise.

twig
{% set visibleTiers = product.visible_price_tiers %}
{% if visibleTiers is not empty %}
    <h4>Volume Pricing</h4>
    <table>
        <thead>
            <tr><th>Quantity</th><th>Unit Price</th></tr>
        </thead>
        <tbody>
            <tr>
                <td>1+</td>
                <td>{{ product.display_price|currency }}</td>
            </tr>
            {% for tier in visibleTiers %}
                <tr>
                    <td>{{ tier.quantity_label }}</td>
                    <td>{{ tier.price|currency }}</td>
                </tr>
            {% endfor %}
        </tbody>
    </table>
{% endif %}

ProductBundleItem

Bundle items represent slots within a bundle product. Each slot offers multiple product choices for the customer to select from.

Properties

PropertyTypeDescription
idintPrimary key
namestringSlot name (e.g., "Processor", "Memory")
descriptionstringSlot description
control_typestringUI control: dropdown, radio, or checkbox
is_requiredboolWhether a selection is mandatory
sort_orderintDisplay order

Relationships

PropertyTypeDescription
productProductParent bundle product
item_productsCollection<BundleItemProduct>Available product choices

BundleItemProduct

Each product choice within a bundle slot, with optional price overrides.

Properties

PropertyTypeDescription
idintPrimary key
productProductThe product being offered
default_quantityintDefault quantity when selected
allow_manual_quantityboolLet customer change quantity
is_defaultboolPre-selected by default
is_activeboolVisible to customers
priceintOverride price (null = use product's own price)
sort_orderintDisplay order

Methods

MethodReturnsDescription
getEffectivePrice()intCalculated price based on override mode

Displaying Bundle Items

twig
{% for slot in product.bundle_items %}
    <div class="bundle-slot">
        <h4>{{ slot.name }}{% if slot.is_required %} *{% endif %}</h4>
        {% if slot.description %}
            <p>{{ slot.description }}</p>
        {% endif %}

        {% if slot.control_type == 'dropdown' %}
            <select name="bundle_items[{{ slot.id }}]"
                    {% if slot.is_required %}required{% endif %}>
                <option value="">— Select —</option>
                {% for choice in slot.item_products %}
                    {% if choice.is_active %}
                        <option value="{{ choice.product.id }}"
                                {% if choice.is_default %}selected{% endif %}>
                            {{ choice.product.name }}
                            ({{ choice.getEffectivePrice()|currency }})
                        </option>
                    {% endif %}
                {% endfor %}
            </select>

        {% elseif slot.control_type == 'radio' %}
            {% for choice in slot.item_products %}
                {% if choice.is_active %}
                    <label>
                        <input type="radio" name="bundle_items[{{ slot.id }}]"
                               value="{{ choice.product.id }}"
                               {% if choice.is_default %}checked{% endif %} />
                        {{ choice.product.name }}
                        ({{ choice.getEffectivePrice()|currency }})
                    </label>
                {% endif %}
            {% endfor %}

        {% elseif slot.control_type == 'checkbox' %}
            {% for choice in slot.item_products %}
                {% if choice.is_active %}
                    <label>
                        <input type="checkbox" name="bundle_items[{{ slot.id }}][]"
                               value="{{ choice.product.id }}"
                               {% if choice.is_default %}checked{% endif %} />
                        {{ choice.product.name }}
                        ({{ choice.getEffectivePrice()|currency }})
                    </label>
                {% endif %}
            {% endfor %}
        {% endif %}
    </div>
{% endfor %}

ProductType

Product types control which features and tabs are available on the product form. They are primarily a backend concept, but can be useful in templates to conditionally render features.

Properties

PropertyTypeDescription
idintPrimary key
namestringType name
codestringAPI code
has_filesboolSupports downloadable files
has_shippingboolRequires shipping
has_inventoryboolTracks inventory
has_optionsboolSupports options
has_extrasboolSupports extras
has_bundlesboolSupports bundles
has_variantsboolSupports variants
is_defaultboolDefault type for new products

Using in Templates

twig
{% if product.product_type.has_shipping %}
    <p>Ships within 3-5 business days</p>
{% else %}
    <p>Digital delivery — instant access after purchase</p>
{% endif %}

{% if product.product_type.has_files %}
    <p>Includes downloadable files</p>
{% endif %}