Guide 08 · Developer docs

Commerce

A Core dna store is a set of records and an API that moves a purchase from cart to order: products described in every language and priced in every currency the store sells in, a cart, a checkout that becomes an order, invoices, payments, tax and shipping. The site builds the storefront on top: the cart and checkout pages, their scripts, and the customer emails.

L2–L3
Unit 01 · Products, prices and catalogue pages

Product, description and variant

A product holds what is sold, its descriptions hold what is said about it, and a variant is a version that is bought on its own. Which record each field belongs on, why copy belongs on the description, and where custom fields stop being the right tool.

Who this is for. The commerce implementer and the content modeller setting up a catalogue, before the first product is entered. Afterwards you can say which record each field belongs on, and you know which mistakes are expensive to undo once a catalogue is full.

Three records, three jobs

Every catalogue entry is made of three kinds of record, and each answers a different question:

RecordHoldsPer languageRevisions
ProductThe commerce data: the SKU (code), one price row per currency, weight, width, height and depth, the main image, the primary category and other categories, the shipping category, the tax category, custom fields, the site, the published date, and the purchasable and available from / until fieldsNo, one record for every languageNo, a change is live when saved
Product descriptionThe content: name, slug, short text, tags, SEO settings, an optional custom URL, and a layout with its layout areasYes, at most one per languageYes: drafts, versions and workflow approval, as for a page
VariantA version of a product that is bought on its own, such as a size or a colour: its own SKU, price rows, weight and dimensions, image and descriptionsIts descriptions areIts descriptions have them

A variant is a full purchasable record, not an option on its product. It takes its product's primary category and shipping category, and it has no custom fields, no other categories and no tax category of its own. Its URL is built from its product's: /products/<category>/<product slug>/<variant slug>.

Two values must be unique, and the admin and the API refuse a duplicate: the SKU, across every product and variant of the account (all sites share the space), and a description's slug, across every product and variant description of the site.

Which record a field belongs on

Three rules decide almost every case:

  • If a shopper reads it, it goes on the description. Headings, marketing copy, specifications written as prose, imagery beyond the main image, the page layout.
  • If the till, the warehouse or the courier uses it, it goes on the product, or on the variant when it differs between versions: the SKU, the price, the weight, the categories that drive shipping and tax.
  • If it differs between versions that are bought separately, it is a variant. A T-shirt in three sizes is one product with three variants, each with its own SKU and stock. Something chosen but not stocked separately, such as an engraving, is an item attribute in the cart instead (item attributes).
FieldRecordWhy there
Product name, short text, long copyDescriptionTranslated and revisioned
Size or colour labelVariant description's nameShown per version, in each language
Price, RRP, sale priceProduct and each variantEvery purchasable record has its own price rows; see Prices and currencies
Weight and dimensionsProduct and each variantRead by shipping methods
A filterable attribute, such as materialProduct custom fieldStructured and the same in every language (below)
A galleryDescription, as a collection areaSee Catalogue pages

Why copy goes on the description

The description is the only one of the three that is versioned and translatable. A product description has revisions like a page: an author can prepare a draft for next week's launch while the current copy stays live, roll back to last month's wording, and send a change through a workflow for approval. Each language has its own description, so the French copy is a separate record with its own revisions.

Put the same copy on the product, for example in a Richtext custom field, and every one of those is lost. A product custom field has one value for all languages, and saving the product publishes the change at once, with no draft and no history. Teams that start this way usually find out when the first translation is commissioned, by which time every product needs its copy moved by hand.

The layout and its layout areas also exist only on the description. A product page with a gallery, a specifications table and a related-products component is the description's layout; see The layout model for how layouts and areas work.

Reading the right description

A product's own getName(), getSlug(), getText() and getFullPath() read its default description, the one in the default language. On a single-language site that is all you need. On a multilingual site, render cards and pages from the description in the visitor's language, with the default as the fallback:

Template code
{* once, above any loop *}
{$language = $_query->findOneBy('Common\Entities\Language', ['code' => $_request->getLanguage()])}

{* per product *}
{$description = null}
{if $language}{$description = $product->getDescriptionForLanguage($language)}{/if}
{if !$description && $product->hasDefaultDescription()}{$description = $product->getDefaultDescription()}{/if}
{if $description}
  <a href="{$description->getFullPath()}">{$description->getName()|escape}</a>
{/if}
{# once, above any loop #}
{% set language = _query.findOneBy('Common\\Entities\\Language', {'code': _request.getLanguage()}) %}

{# per product #}
{% set description = language ? product.getDescriptionForLanguage(language) : null %}
{% if not description and product.hasDefaultDescription() %}{% set description = product.getDefaultDescription() %}{% endif %}
{% if description %}
  <a href="{{ description.getFullPath() }}">{{ description.getName()|e }}</a>
{% endif %}
Check before you get a default

Test with hasDefaultDescription() or hasDefaultPricing() before calling getDefaultDescription() or getDefaultPricing(), as above, and read prices with getFinalPrice().

For every language a product has, loop over getDescriptions(): it returns all of the product's descriptions, one per language. A category has the same methods for its category descriptions: getDescriptionForLanguage(), getDescriptions(), hasDefaultDescription(), getDefaultDescription() and getFullPath() work on a category as they do on a product.

A description's URL in each language

Each description has its own slug, and a slug is unique across the site, so the English and French descriptions of one product have different slugs and different URLs. getFullPath() builds the path of the description it is called on: /products/, the slugs of the product's primary category and that category's parents, then the description's own slug. The category slugs come from each category's description in the default language, whatever the language of the product description, so the last segment is the one in the description's language: the French description of an enamel mug is at /products/kitchen/mugs/tasse-emaillee. A variant's path takes its product's slug from the product's default description in the same way. Link to a description with its own getFullPath().

A product page offers its other languages from the product's descriptions. Walk getDescriptions(), leave out a description with no language or whose language has no LCID, and use the LCID, getLcid() (such as en-US or fr-FR), as the hreflang value rather than the two-letter getCode(): several languages can share a code, as en-AU and en-US do. Include the page's own description, and give the default language's description as x-default as well. The platform's sitemap builds the alternates of each product by the same rules (Catalogue pages), so the page and the sitemap agree. Search engines expect absolute URLs in these tags, so put the site's address in front of each path.

The same loop gives a visible language switcher, with a link per language in place of the <link> tags, and a category page's alternates, over $category->getDescriptions(). The tags, in the product page's <head>:

Template code templates/products/product-descriptions/show.html
{* the site's address, without a trailing slash *}
{$siteUrl = 'https://'|cat:$_currentSite->getPrimaryEnvironmentPrimaryHostname()}
{$product = $productDescription->getProduct()}
{$defaultLanguageId = $_config->get('Globals.defaultLanguage')}
{foreach $product->getDescriptions() as $other}
  {$otherLanguage = $other->getLanguage()}
  {if $otherLanguage && $otherLanguage->getLcid()}
    <link rel="alternate" hreflang="{$otherLanguage->getLcid()}" href="{$siteUrl}{$other->getFullPath()}">
    {if $otherLanguage->getId() == $defaultLanguageId}
      <link rel="alternate" hreflang="x-default" href="{$siteUrl}{$other->getFullPath()}">
    {/if}
  {/if}
{/foreach}
{# the site's address, without a trailing slash #}
{% set siteUrl = 'https://' ~ _currentSite.getPrimaryEnvironmentPrimaryHostname() %}
{% set product = productDescription.getProduct() %}
{% set defaultLanguageId = _config.get('Globals.defaultLanguage') %}
{% for other in product.getDescriptions() %}
  {% set otherLanguage = other.getLanguage() %}
  {% if otherLanguage and otherLanguage.getLcid() %}
    <link rel="alternate" hreflang="{{ otherLanguage.getLcid() }}" href="{{ siteUrl }}{{ other.getFullPath() }}">
    {% if otherLanguage.getId() == defaultLanguageId %}
      <link rel="alternate" hreflang="x-default" href="{{ siteUrl }}{{ other.getFullPath() }}">
    {% endif %}
  {% endif %}
{% endfor %}

Custom fields, and when to stop using them

Products and categories carry custom fields (variants and descriptions do not). A custom field is right for a handful of structured attributes that belong to the product and read the same in every language: a material, a country of origin, a care code, a flag such as vegan. Read them with the custom-field getters, for example {$product->getCustomFieldText('material', '')} in Smarty or {{ product.getCustomFieldText('material', '') }} in Twig; Custom fields has the full set.

Stop and use a custom entity instead when an attribute crosses any of these lines:

  • It has its own page or listing. A brand with a logo, a story and a page of its products is a record, not a field.
  • It has its own lifecycle. An ingredient whose supplier, certificate or allergen status changes on its own schedule should be updated once, not on every product that contains it.
  • It needs translation or revisions. A custom field has neither.
  • It is shared by many products and must stay consistent. Twenty products typing "Organic cotton" will soon say it four ways.

The usual threshold: once the same value is typed on more than a few products, or anyone asks for a page about it, model it as a custom entity record and relate the products to it. Decide between a custom field and a layout area covers the neighbouring choice, between a field and a layout area.

Go deeper

Was this page helpful?

Your answer goes to the docs team.