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
Products, prices and catalogue pagesPage 1 of 31Updated
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:
Record
Holds
Per language
Revisions
Product
The 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 fields
No, one record for every language
No, a change is live when saved
Product description
The content: name, slug, short text, tags, SEO settings, an optional custom URL, and a layout with its layout areas
Yes, at most one per language
Yes: drafts, versions and workflow approval, as for a page
Variant
A 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 descriptions
Its descriptions are
Its 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).
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:
{# 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().
Language links and hreflang
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>:
{# 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.