Catalog module

Module id: catalog. Dependencies: none.

What it adds:

  • Sidebar tabs for Products, Categories, Tags, Tax classes.
  • Product variants with option types (size, color, …).
  • A search-form helper + a category/tag/product/featured-products helper for theme templates.
  • The cms_money filter for formatting integer minor-units (cents) with currency + locale.

Catalog is the foundation for an e-commerce site, but useful by itself too — content sites that just want a "Featured products" section on the homepage can enable it without going to Cart / Checkout.

What's per-website

Nothing on a settings entity. All catalog data lives on the domain entities (Product, Category, Tag, TaxClass, ProductVariant, ProductOptionType, ProductOptionValue), each scoped to a Website by Doctrine's WebsiteFilter. There is no "catalog settings" panel — what you'd configure is per-product or per-category, edited where you create them.

Catalog → Products

Each product carries:

Field Notes / example
Name "Powder brow refill 6 weeks"
Slug URL slug, auto-derived from name; editable per locale.
SKU Stable internal id (often empty for service-only "products").
Brand The brand it is sold under. Blank: the shop's default brand applies.
Barcode (EAN / GTIN) 8, 12, 13 or 14 digits; one with a wrong check digit is refused. Blank: none.
Short description One-line marketing line for listings.
Description Rich text (TipTap) for the product detail page.
Price (cents) 4500 = €45.00 in the website's default currency.
Currency ISO code (EUR, USD, …). Inherited from website unless overridden.
Tax class One of the configured tax classes — drives gross-price math.
Stock + Track stock? When Track stock = on, checkout decrements; out-of-stock hides Add-to-cart.
Weight (grams) Used by shipping integrations later; safe to leave 0 for service products.
Images Reuses the per-website Media library. Multiple per product; first is primary.
Categories Many-to-many. Affects cms_products('category-slug') listing.
Tags Free-form labels. Listing helper cms_products_by_tag('tag-slug').
Featured? Drives cms_featured_products() listing.
Variants? + Option types Toggle to expose per-size / per-color SKUs — see Variants.
SEO override Optional title / meta-description / og-image override.
Published? Off → invisible to the public site; visible in admin only.

Realistic example — a service product

A salon offering a "Brow refill" treatment:

Name:               Powder brow refill (6 weken)
Slug:               powder-brow-refill-6-weken
SKU:                pmu-pb-r6
Short description:  Bijwerksessie 4–6 weken na de eerste behandeling.
Price (cents):      9500     # €95
Currency:           EUR
Tax class:          BTW 21%
Stock:              0
Track stock:        off       # services don't have stock
Weight:             0
Categories:         [Permanente make-up, Wenkbrauwen]
Tags:               [refill, powder]
Featured:           off
Images:             [refill-result-1.jpg, refill-result-2.jpg]
Published:          on

In the theme:

{% set p = cms_product('powder-brow-refill-6-weken') %}
{% if p %}
    <article>
        <h1>{{ p.name }}</h1>
        <p class="lead">{{ p.shortDescription }}</p>
        <div class="prose">{{ p.description|raw }}</div>
        <p class="price">{{ cms_price(p) }}</p>
    </article>
{% endif %}

cms_price(product, mode = 'gross', locale = null) returns a ready-to-display string like "€95,00" — currency + locale formatting already applied.

Catalog → Categories

Hierarchical (parent / children). Each category has slug, name, description, image, nav order, published flag, SEO override.

A category's slug is what you pass to cms_products(slug) to scope a product listing:

<h2>Wenkbrauwen</h2>
{% for p in cms_products('wenkbrauwen') %}
    <a href="/products/{{ p.slug }}">
        <img src="{{ p.images[0].path }}" alt="{{ p.images[0].alt }}">
        <span>{{ p.name }}</span>
        <span>{{ cms_price(p) }}</span>
    </a>
{% endfor %}

cms_categories(opts = {}) returns the full tree, root-first. Pass {root: 'wenkbrauwen'} to scope to one branch.

Catalog → Tags

Flat (no hierarchy). Useful for cross-cutting labels like "new", "sale", "limited". Tags are not navigation-first the way categories are; surface them where it makes sense (sidebar filter, badge on product cards).

Catalog → Tax classes

Per-website list of tax rates, expressed in basis points (so 21% is 2100 bp). Each product references one tax class. The cms_price() helper applies the rate when mode='gross' (default) and shows the net price when mode='net'.

For a Dutch salon:

Code: btw-hoog        Label: BTW 21%       Rate (bp): 2100
Code: btw-laag        Label: BTW 9%        Rate (bp):  900
Code: btw-vrij        Label: Vrijgesteld   Rate (bp):    0

Variants

Optional per-product. When you toggle Has variants? on a product, the admin reveals an Option types builder + a variants table.

Option types are reusable per-product axes:

Option type:  Color     Values: [Rood, Zwart, Naturel]
Option type:  Volume    Values: [10ml, 20ml, 50ml]

Variants are the cartesian product of selected option values, each with its own SKU, barcode, price, stock and weight. The product's "base" price applies to variants that don't override.

Barcodes per variant. Each size or colour is a trade item of its own with its own EAN, so the variants table has a Barcode column next to the SKU. It takes the same 8, 12, 13 or 14 digits as the product's barcode, spaces and hyphens are dropped, and a number with a wrong check digit is refused with the reason under the cell. Blank means the variant has none. A variant never uses the product's barcode: one number on every size would make them all the same item to Google. A variant SKU that is itself a valid barcode does stand in, as it does for a product.

In the theme: walk product.variants, render a picker, post the selected variant.id to /cart/add. The cart line snapshots the variant's price + name, so a later price change doesn't retroactively rewrite cart totals.

{% set p = cms_product('parfum-amber') %}
{% if p.hasVariants %}
    <select name="variant_id">
        {% for v in p.variants %}
            {% if v.isAvailable %}
                <option value="{{ v.id }}">
                    {{ v.name }} — {{ v.priceCents|cms_money(p.currency) }}
                </option>
            {% endif %}
        {% endfor %}
    </select>
{% endif %}

Brand and barcode in a product CSV import

The column mapping step guesses the brand and barcode columns from the headers stock exports use; change the guess if it picked the wrong one.

Field Headers it recognises
Brand Brand, Brands (WooCommerce), Vendor (Shopify), Merk, Merken
Barcode Variant Barcode (Shopify), GTIN, UPC, EAN, or ISBN (WooCommerce), Barcode, GTIN, EAN, EAN-code

One barcode column serves products and variants alike, the way the SKU column does: on a product row (WooCommerce simple or variable, every row of a Shopify export) the barcode goes on the product; on a WooCommerce variation row it goes on that variant.

On a product row a blank cell clears the brand or barcode, like the other optional columns; on a variation row a blank barcode leaves the variant's as it was.

A barcode that fails its check digit refuses the whole row. The row is listed in the import's error report (and the error CSV) with the value it had, for example Not a valid barcode: expected 8, 12, 13 or 14 digits with a correct check digit. Got "8,71234E+12". Nothing of that row is imported. That last example is the usual cause: a spreadsheet program turned the barcode into a number in scientific notation. Format the column as text, fix the cells and run the import again; rows already imported are updated by SKU.

Images in a product CSV import

A product import (a Shopify or WooCommerce CSV) downloads every URL in the images column into the website's Media library. The worker does the downloading from inside the cluster, so it only fetches URLs that point at the public internet:

  • http:// or https:// only, with no login in the URL (https://user:pass@host/... is refused).
  • The host has to resolve to a public address. Loopback (localhost, 127.0.0.1), private networks (10.x, 172.16.x to 172.31.x, 192.168.x, fc00::/7), link-local (169.254.x, which includes the cloud metadata address), carrier-grade NAT (100.64.x), multicast and the other reserved ranges are refused, also when a public URL redirects there. A host that does not resolve at all counts as refused too.
  • At most 3 redirects, 10 MB and 20 seconds per image.

A refused image does not fail its row: the product is imported with the images that did come through, and the worker logs an Image fetch failed during import warning naming the URL and the SKU. Images on an intranet host or a staging box only the office network can reach will not import; put them on a public host first, or upload them to the Media library and attach them by hand.

Structured data on product pages

With the SEO module on, every product page carries JSON-LD that Google reads for rich results and its free shopping listings. It states what the page states, from the same code as the page and the Merchant Center feed.

A product without variants (or with its variants switched off) is a Product with one Offer:

  • name, SKU, URL, description, pictures and first category;
  • brand: the product's, else the shop's default brand (Checkout settings, Google shopping listings);
  • gtin: the barcode, else the SKU when that is a valid barcode;
  • the offer: the shelf price incl. VAT with any catalog discount applied, in stock or not, and the shop's shipping and return terms.

A product with variants is a ProductGroup, the shape Google reads variants from:

  • the group carries what the variants share: name, URL, brand, description, pictures, category, and productGroupID, which is the product's SKU. It states no barcode and no offer: it is not something a customer can buy;
  • under hasVariant, one Product per published variant: the name with the variant's label ("T-shirt (Red / M)"), the variant's SKU, its own barcode (else its SKU when that is a valid barcode, never the product's), its picture (else the product's first), the product's SKU as inProductGroupWithID, and an offer of its own at the price the page shows once that variant is picked, in stock by the variant's own count, with the shipping that price gets;
  • variesBy names the options the variants differ by, when Google has a property for every one of them: an option called Size, Maat, Grootte, Größe or Taille is size; Color, Colour, Kleur, Farbe or Couleur is color; Material, Materiaal, Matériau or Matière is material; Pattern, Patroon, Muster or Motif is pattern. Each variant then also states its value ("size": "M"). A product that also varies by something else (Scent, Amount) states no variesBy at all rather than half of it; the variants' names still tell them apart.

The product page has no address of its own per variant, so every variant's offer points at the product page.

Theme helpers (when catalog is enabled)

Full list in ../../twig-helpers.md#catalog-module-catalog. TL;DR:

{# Featured products on the homepage. #}
{% for p in cms_featured_products({limit: 6}) %}
    <article>
        <a href="/products/{{ p.slug }}">{{ p.name }}</a>
        <p>{{ cms_price(p) }}</p>
    </article>
{% endfor %}

{# Search box + result list. #}
{{ cms_search_form() }}
{{ cms_search_results() }}

{# Format a raw cents value with the website's currency. #}
{{ 12500|cms_money('EUR', 'nl_NL') }}    {# → "€125,00" #}

Public-side templates (themes can override)

Page templates rendered by the catalog routes — copy the file under templates/ in the theme repo to override:

Default Override at
@GsidCmsCore/pages/product.html.twig @website/pages/product.html.twig
@GsidCmsCore/pages/products.html.twig @website/pages/products.html.twig
@GsidCmsCore/pages/category.html.twig @website/pages/category.html.twig
@GsidCmsCore/pages/tag.html.twig @website/pages/tag.html.twig
@GsidCmsCore/pages/search.html.twig @website/pages/search.html.twig
@GsidCmsCore/catalog/_search_form.html.twig @website/catalog/_search_form.html.twig
@GsidCmsCore/catalog/_search_results.html.twig @website/catalog/_search_results.html.twig

Public routes

The catalog module mounts the following routes when enabled:

Route Path
cms_products_index /products
cms_product_show /products/{slug}
cms_category_show /categories/{slug}
cms_tag_show /tags/{slug}
cms_search /search

These routes coexist with theme-defined pages — if you have a Page with slug products, the page wins (the page renderer matches first). Use that if you want a CMS-edited landing on top of the catalog list.

Things this module does NOT do

  • No checkout / cart. Those are separate modules; catalog only lists. To accept orders, also enable Cart + Checkout.
  • No inventory management beyond a single integer. No warehouses, no SKU-level location tracking. Stock is one number per product/variant.
  • No price tiers / customer-group pricing. Single price per product/variant.
  • No "abandoned cart" automation, no recommendations engine, no reviews. v1 surface only.