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
Catalogue pages: categories, listings and products
Products, prices and catalogue pagesPage 3 of 31Updated
The routes, templates and variables of the product listing, the category page and the product page; a product page with variant selection and a gallery; and how to keep listings, the slowest pages on most stores, from running a handful of queries per product.
Who this is for. The theme developer writing the catalogue templates. Afterwards you know which template each catalogue URL renders and what it receives, you can build a product page that offers its variants, and you can tell a listing that will stay fast from one that will not.
The routes and their templates
The Products module answers under /products. The site supplies each of its templates; a route whose template is not in place answers with the text Template not found: <path>. Request each route once after deploying a new store.
URL
Template
Variables
/products, /products/2 and so on
products/products/index.html
products, one page of published products; _pagination
/products/descriptions, /products/descriptions/2
products/product-descriptions/index.html
productDescriptions; _pagination
/products/category/<slug>
products/category-descriptions/show.html
categoryDescription; products, every product in the category
/products/<category path>/<slug>
The template of the description's layout, or products/product-descriptions/show.html when it has none
productDescription (also as record); _recaptcha for guests when back-in-stock requests are open to them
A listing shows 15 records a page unless Globals › Pagination says otherwise. Only published products and categories are shown, and only those of the current site or of no site.
How a product URL is found
A product's URL is its description's: /products/, the slugs of its primary category and that category's parents, and the description's slug, for example /products/kitchen/mugs/enamel-mug. A variant adds its product's slug before its own. Print it with getFullPath(), never build it by hand.
The page is found by its last segment: /products/anything/enamel-mug renders the same page as the product's own path. Two consequences:
Give every product page a canonical link, <link rel="canonical" href="{$productDescription->getFullPath()}"> in Smarty or {{ productDescription.getFullPath() }} in Twig, so search engines see one URL.
A product with no primary category has no path at all (getFullPath() returns an empty string), and a URL with a single segment after /products/ does not resolve to the product. Give every product a primary category.
URLs and languages
The slug also decides the language. Each description has its own slug, so each language of a product has its own URL, and the page shows the description that owns the slug in the URL, in that description's language, whatever language the visitor's browser prefers. So the language is chosen where links are built: a listing links to the description in the visitor's language with getDescriptionForLanguage() (Reading the right description), and the product page links to its other languages (Language links and hreflang).
The platform's sitemap lists every published product description while Products.generateInSitemap is on, as it is by default. Each entry carries the product's descriptions in every language as alternates, keyed by each language's LCID, such as fr-FR, with the default language's description also given as x-default. The tags on the product page follow the same rules, so the page and the sitemap say the same thing.
The category page
A category is a record in a tree, with a position, a published date and custom fields; its content, like a product's, is a category description per language, revisioned and carrying a layout with areas exactly as a page does. Products join categories through category elements, which also hold each product's position in that category; a product's primary category is the one that builds its URL.
The category page is found by the description's slug alone, /products/category/mugs, whatever the category's parents and whatever the visitor's language: as on the product page, the slug picks the description, and so the language. It receives categoryDescription and products: every product assigned to that category, in position order, with no paging and without the products of its child categories. Two differences from the product page to plan for:
The live page renders one template. Every category page renders products/category-descriptions/show.html, so put the category markup there. Read the layout areas in that one template, with getAreaText(), getAreaHtml() and the other area getters on categoryDescription.
Page it yourself.products holds the whole category, with no _pagination or record variable: stop the loop after a page's worth, as below.
For the tree itself, a category has getParent(), getChildren() and getPosition(), and a product has getPrimaryCategory() and getProductCategories().
{% set category = categoryDescription.getCategory() %}
<h1>{{ categoryDescription.getName()|e }}</h1>
{{ categoryDescription.intro }}
<nav class="subcategories">
{% for child in category.getChildren() %}
<a href="{{ child.getFullPath() }}">{{ child.getName()|e }}</a>
{% endfor %}
</nav>
<ul class="product-grid">
{% for product in products|slice(0, 24) %}
<li><a href="{{ product.getFullPath() }}">{{ product.getName()|e }}</a></li>
{% endfor %}
</ul>
Categories in several languages
A category has one description per language, like a product, each with its own slug and so its own URL. It has the same methods for reaching them: getDescriptionForLanguage() for the visitor's language and getDescriptions() for all of them (Reading the right description). Give a category a description in every language the site supports before a translated product or a translated parent category links to it, so that every language has a category page to arrive on.
The sitemap covers product pages; a category page carries its language alternates in its own <head>. Use the product page's loop over $category->getDescriptions() (Language links and hreflang).
The product page and its variants
productDescription is the description being shown; its getProduct() is the record behind it. That record is a variant when the URL is a variant's, so a product page that lists variants first steps up to the product. Each variant is a purchasable record with its own SKU and price, so variant selection is a choice of SKU: the form sends the chosen variant's code to the cart, and the page script adds that SKU (Changing the cart).
{% set product = productDescription.getProduct() %}
{% if product.isVariant() %}{% set product = product.getProduct() %}{% endif %}
{% set currency = _query.findOneBy('Common\\Entities\\Currency', {'id': _config.get('Products.defaultCurrency')}) %}
{% set variants = product.getVariants() %}
<link rel="canonical" href="{{ productDescription.getFullPath() }}">
<h1>{{ productDescription.getName()|e }}</h1>
{{ productDescription.description }}
<form class="add-to-cart">
{% if variants|length > 0 %}
<label for="sku">Choose an option</label>
<select id="sku" name="sku">
{% for variant in variants %}
{% set price = variant.getFinalPrice(currency) %}
{% if price is not null %}
<option value="{{ variant.getCode()|e }}" data-price="{{ price }}">{{ variant.getName()|e }}: {{ currency.getSymbol() }}{{ price|number_format(currency.getDecimalPlaces()) }}</option>
{% endif %}
{% endfor %}
</select>
{% else %}
<input type="hidden" name="sku" value="{{ product.getCode()|e }}">
{% endif %}
<label for="quantity">Quantity</label>
<input id="quantity" name="quantity" type="number" min="1" value="1">
<button type="submit">Add to cart</button>
</form>
Leaving out variants with no price in the currency matters: the cart refuses them. The cart also refuses a product or variant that is unpublished or belongs to another site, and the purchasable switch and the available from / until dates are for the template to act on: hide the button in the template when the store uses them, and unpublish a product to stop it being sold.
Images and galleries
A product or variant has one main image, getImage(), which is null when none is set. A gallery belongs on the description, as a component collection area whose component has an image area (and, if you want per-image alt text, a text area). The order of the components is the order an author sets by dragging them in the admin, so the template walks the collection in that order. Generate the sizes each place needs with getResized(): a square crop for cards, a larger fit for the detail view.
{% for element in productDescription.getAreaCollectionElements('gallery') %}
{% set component = element.getComponent() %}
{% set image = component.getAreaImage('image') %}
{% if not image.isBlank() %}
{% set alt = component.getAreaText('alt')|e %}
<a href="{{ image.getResized(1600, 1600, 'fit').getPath() }}">
{{ image.getResized(600, 600, 'crop').getHtml({'alt': alt, 'loading': 'lazy'}) }}
</a>
{% endif %}
{% endfor %}
Escape the alt text before passing it: getHtml() writes attribute values as given. Resize strategies, WebP, picture tags and lazy loading are the same for product images as for any other, and are covered once in Images in templates and Image URL parameters.
Keeping listings fast
Listings are the commonest slow page on a store, and the cause is always the same: every product card reads records the product only points at, and each is loaded from the database the first time it is reached. A card that prints a name, a link, a price and an image reaches the product's default description (name and path), its price rows, its image, and its primary category with that category's description (for the path). That is several queries per card, so a grid of 24 runs around a hundred. Records the cards share, such as the category, are loaded once. So keep the per-card reads few and the expensive pages rare:
Let the page cache work. A listing that prints nothing about the visitor is cached, and its queries run once per cache lifetime instead of once per view. Anything personal makes it uncacheable or, worse, caches one visitor's state for everyone: the cart ($_cart), isInWishlist(), anything about the signed-in member. Render those in the browser; see The cart count on a cached page.
Hoist what is the same for every card above the loop: the currency, the language, labels, settings, and any other record looked up with $_query. One lookup with an | (in) filter before the loop replaces one per card; Fetching once instead of per iteration shows the pattern in both engines.
Read little per card. Name, link, price, image. A variant list on a card costs a query for the variants and another for each variant's prices; a category name or a wishlist check on every card adds more. Put those on the product page.
Always limit. Paginate the /products listing (it pages by itself), stop the category page's loop after a page, and give every $_query->findBy() a limit.
For filtering and large catalogues, render from the search index. A faceted search view indexes product descriptions with their name, path, category path and language, and the view's template can add more, such as prices; a listing built from it reads no records per card. Facets covers the view.