Skip to content

Powered by Grav CMS + Helios Theme

Multilanguage

KahunaCart stores catalog translations in its database, separately from Grav's translated page files. This page covers what can be translated, how a translation resolves, and how to edit translations from the admin, the API or a catalog file.

Note

If your site runs one language, nothing on this page applies. With no system.languages.supported configured, no extra table is read, no extra query is issued, and the admin draws no extra controls. This is covered by tests/Unit/TranslatedCatalogTest.php.

Before you begin

  • Grav's system.languages.supported lists the languages the site runs in.
  • Migration 0012_translations has been applied, either by auto-migrate or by bin/plugin kahunacart migrate.
  • ext-intl is installed, if you want locale-aware price formatting. Without it, Money::format() falls back to USD 28.00 in every language.

Turn on the multilanguage catalog

  1. Add the languages to user/config/system.yaml:

    YAML
    languages:
     supported: [en, de]
    
  2. Run bin/grav clearcache.

  3. Run bin/plugin kahunacart migrate if your store does not auto-migrate.

  4. Translate a product from the admin, or PATCH one through the API.

  5. Visit /de/shop.

The storefront now serves a German catalog at /de/shop and falls back to the base text for anything untranslated.

To turn it back off, remove supported and clear the cache. The storefront returns to base text everywhere. The translation rows stay in the database and come back when the language does.

What can be translated

Entity Fields
Product title, summary, description
Category title, description
Tag label — "Organic", "Bio"
Attribute label — "Material", "Waterproof"; and group_label, the heading it sits under in the specification table
Option axis name — "Size", "Colour"
Option value label — "M", "Navy"
Shipping method label — what the checkout picker offers and what lands on the receipt
Tax rate label — "VAT", "MwSt."
Sale label — the badge on a product card and the discount line on the receipt
Manufacturer safety_info, the default Product safety text for products that have none of their own

classes/I18n/Translatable.php defines these ten entity types. All writes are checked against it. API requests that name a non-translatable field and import files that name an unsupported entity type are rejected.

What is not translated

Item Behaviour
Slugs One product, one URL path, in every language. /en/shop/product/reef-runner-tee and /de/shop/product/reef-runner-tee are the same product with different words on it.
Order lines and adjustments Frozen. A line title is snapshotted at add-to-cart time in the cart's language; adjustment labels are written once during the calculation that produced them.
Variant titles A variant's customer-facing name comes from its combination, M / Navy, built from axis and value labels. Those are translatable. kahunacart_variants.title is the older free-text field for a variant outside a matrix.
Attribute option values The attribute label and group_label translate; the values a select or multiselect attribute offers do not, so a German spec table reads Material: Cotton. To translate a facet's values, model it as an option axis or as tags instead.
Zone names, tax classes, shipping classes Back-office labels and keys.
Sale names A sale's label is translatable because a customer reads it. Its name is what the merchant calls the campaign in the back office.
Coupon codes The customer types them, so they must be the same string everywhere.
Product file names A file name is a file name.
Plugin interface strings Ordinary Grav language keys under PLUGIN_KAHUNACART.* in languages/. "Add to cart", "Your cart is empty" and the checkout validation messages come from there.
Plugin config strings shipping.label, tax.label, offline.label, offline.instructions and the invoice.* block are single values in kahunacart.yaml.

Note

If a German storefront shows English button text, you are missing languages/de.yaml, not a catalog translation.

The plugin's own words (buttons, checkout messages, emails and their subjects, the admin's labels) come from languages/en.yaml. A second language is a second file beside it. Copy de.yaml into user/plugins/kahunacart/languages/, run bin/grav clear-cache, and the storefront on /de renders in German. A key the pack lacks falls back to English per key. bin/plugin kahunacart lang:export --status says how complete each pack is, and Translating KahunaCart is the guide to writing and submitting one.

Important

The flat-rate shipping and tax stages read the config labels, so a store that has not configured zones gets one label in every language. Configure zones and their labels become translatable rows.

Note

A shared slug keeps every link, bookmark, QR code and pasted URL working when you translate a product. The SEO cost is real: a German-language URL containing an English slug is weaker than a fully localized one.

How translations are stored

One table, added by migration 0012_translations:

TXT
kahunacart_translations(entity_type, entity_id, language, field) -> value

It has a unique key over those four columns and an index on (entity_type, language, entity_id). Writes go through Dialect::upsert(). Reads are batched by entity type, so a page of twenty-five products costs one extra SELECT.

There are no foreign keys, so each deletion path sweeps its own translations: ProductRepository::deleteWithVariants(), ProductOptionRepository::deleteOption(), CategoryRepository::deleteReparenting() and the shipping and tax deleteForZone() methods.

A missing row is the fallback. Clearing a field in the admin deletes its row. An empty string is never written.

How translations resolve

KahunaCart::activeLanguage() decides the active catalog language. It returns Grav's own $grav['language']->getLanguage() when languages are enabled, and null otherwise. null means "this store has no languages", which makes the single-language path a no-op.

The fallback chain is active language → site default language → the base columns on the row.

Resolution is per field, not per entity. A product with a German title and no German summary renders the German title and the English summary.

Routing

Grav strips the language prefix from the request path before any plugin sees it, so $uri->path() reads /shop/product/foo whichever language was asked for. The plugin keeps two answers to "where does the storefront live":

Method Returns Used for
KahunaCartFrontend::storeRoute() /shop Matching an incoming request. The one URL that means the same thing in every language.
KahunaCartFrontend::baseRoute() /de/shop Every link, form action, redirect, canonical URL and og:url.

The prefix comes from Grav's own getLanguageURLPrefix(). A site that runs its default language without a prefix (include_default_lang: false) gets /shop for the default and /de/shop for the rest.

Important

The webhook URL is the one exception. {site}/shop/webhook/{provider} is built from the unprefixed route, because a payment provider stores it and calls it for years with no customer attached. Payment return and cancel URLs do carry the prefix, because they bring a customer back.

Formatting

Prices are formatted in the active language's locale everywhere they render: the plugin's templates, the kahunacart_price() Twig function and filter, and the JSON payload the option picker swaps prices from. /en prints $28.00; /de prints 28,00 $.

The locale comes from I18n\Locales::forLanguage(): de-CH becomes de_CH, and de stays de. A bare language code is a valid ICU locale, so de and de_DE both print 58,00 €. Grav's own override_locale is not used, because it produces non-locales such as en_EN and ja_JA.

Note

There is no per-language price. A price belongs to a currency, not a language: a store that sells in more than one currency has a price for each currency, and only the formatting changes with the locale. See Currencies.

SEO

On a product or category page the plugin emits:

  • a canonical URL carrying the language prefix, so each translation points at itself;
  • JSON-LD with inLanguage on the Product and the BreadcrumbList, and with the translated name, description and breadcrumb names;
  • og:locale, plus the translated og:title and og:description.

inLanguage is omitted on a single-language site.

Important

Two of those three depend on the theme. The JSON-LD is written into the page directly and is always there. The canonical and the og: and twitter: tags are handed to Grav's page metadata, which a theme renders by emitting page.metadata in its head. A theme that builds its head by hand from page.header values drops all of them silently. If a storefront page has no canonical and no og:locale, check the theme's head block first.

Sitemap

With grav-plugin-sitemap installed, every storefront URL is contributed once per configured language, each carrying hreflang alternates naming all the others:

XML
<url>
  <loc>https://example.com/de/shop/product/reef-runner-tee</loc>
  <xhtml:link rel="alternate" hreflang="en" href="https://example.com/en/shop/product/reef-runner-tee" />
  <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/shop/product/reef-runner-tee" />
  <lastmod>2026-08-18</lastmod>
</url>

This matches what the sitemap plugin does for ordinary pages when multilang_enabled is on, which is its default. Verified against sitemap 5.2.2.

Every language gets an entry whether or not the product has been translated into it. The store's own multilang_enabled setting is not consulted for these URLs.

Search on /de matches the German text and the base columns, so a store part-way through translating its catalog can still find every product from the /de search box.

The translated match is an EXISTS against kahunacart_translations in the same query, so a product whose title, summary and description all match still comes back once.

Note

The LIKE caveats from Storefront still apply. SQLite's LOWER() only folds ASCII, so a search there for an accented capital matches only what a plain lowercase comparison would. On MySQL and PostgreSQL the collation does the work.

Orders and email

An order records the language it was shopped in, in kahunacart_orders.language. The storefront stamps it on every cart write, so a customer who switches language mid-cart sees the totals relabel. Completion freezes it.

NULL covers every order placed before this migration and every order on a single-language store. It means "the site's default".

Three things read the column:

  • The calculator. The shipping and tax stages translate their labels against the order's language, not the request's. A webhook completing an order and a merchant recalculating one from an English admin both produce the customer's words.
  • The cart. A line item's title is snapshotted in the cart's language, then frozen.
  • The queued emails. Order confirmation, payment received, shipped, refunded and abandoned-cart emails all render in the order's language: subject line, both templates and the money formatting.

A queue drained from cron has no request or session, so I18n\RenderLanguage sets the active language for each render and restores the previous one afterwards.

Note

A language the site no longer supports is ignored rather than forced, because Grav's setActive() validates against the configured list. An order stamped de on a store that has since dropped German renders in the default language.

Note

The set-password invitation email does not render in the order's language. A customer record carries no language; only orders do.

Translate a product in the admin

  1. Open the product in the admin and go to the Details panel.
  2. Click the language tab you want, next to Base (en).
  3. Enter the overrides. Each field shows the base text as its placeholder, and a "falls back to the base" note when it is empty.
  4. Click Save.

A dot on a tab means that language carries at least one override. Saving an empty field removes the override; it never stores an empty string.

Categories get the same switcher in their inline editor. Option axes get one switcher per axis, covering the axis name and every one of its value labels in a single Save translations click.

Shipping methods and tax rates get a row of per-language label inputs inside their existing editors. Their row ids change on every save, so the overrides ride through the form and are posted back with the save.

A manufacturer's Default safety information gets the same switcher in its editor. A product's own safety information is translated on the product. A product with none of its own shows its manufacturer's default text in the language being browsed, and falls back to the base text for a language with no translation. A manufacturer's name, address and contact are a legal record and are not translated.

Translate through the API

Every catalog GET carries a translations map, unresolved:

JSON
{
  "id": 11,
  "title": "Reef Runner Tee",
  "translations": {
    "de": { "title": "Riffläufer-Shirt", "summary": "Ein Baumwollshirt" }
  }
}

The map is unresolved so an editor can tell a field that is falling back from one that is translated.

POST and PATCH accept the same key, following the category_ids rule: absent means leave them alone; present means these are the overrides now. A language missing from the map is dropped, and a field sent empty inside a language clears that one override.

BASH
curl -X PATCH .../api/kahunacart/products/11 \
  -H 'Content-Type: application/json' \
  -d '{"translations": {"de": {"title": "Riffläufer-Shirt"}}}'

The same contract applies on /kahunacart/categories/{id}, /kahunacart/tags/{id}, /kahunacart/attributes/{id}, /kahunacart/sales/{id}, /kahunacart/manufacturers/{id}, /kahunacart/products/{id}/options/{optionId}, /kahunacart/products/{id}/options/{optionId}/values/{valueId}, and, inside each method or rate object, on /kahunacart/shipping/zones/{id} and /kahunacart/tax/zones/{id}.

A map that is wrong is refused whole

The map replaces every language at once, so a mistake in one language is never skipped. The request is a 422, the message names what is wrong, and nothing in the request is saved: not the translations, and not the other fields in the same body. The record keeps every language it had.

The rules are short:

  • The map is a language code to an object of field to text. {} is fine and means "no translations now".
  • A language code starts with a letter and holds letters, digits, - and _: de, pt-br, zh_CN.
  • A field has to be one the record translates. Anything else is refused with the list of the ones that are.
  • A text is a string. null reads as empty, and empty clears that override. A number, a boolean, a list or an object is refused, not converted.
JSON
{"status": 422, "title": "Validation failed", "detail": "translations.de must be a mapping of field to text."}

The other messages follow the same pattern: translations.de.title must be text., and translations.de.colour is not a translatable field; expected one of: title, description. On a shipping or tax zone the path starts at the row, by its label: methods.Express.translations.de.label must be text.

translations is written the same way on every record that has one: products, categories, tags, attributes, options and their values, sales, manufacturers, and each shipping method and tax rate inside its zone. Leave it out and the stored translations are left alone. Send it and it replaces them all. A shipping method or a tax rate is the one exception, because a zone is saved with its whole set of rows. A row sent without translations has none afterwards, so send each row's map back with it, as the admin does.

GET /kahunacart/config reports languages and default_language, which is what the admin reads to decide whether to draw a switcher at all.

Export and import translations

bin/plugin kahunacart export writes translations into the catalog file, and import reads them back:

YAML
products:
  - slug: reef-runner-tee
    title: Reef Runner Tee
    translations:
      de:
        title: Riffläufer-Shirt
        summary: Ein Baumwollshirt
    options:
      - name: Colour
        translations:
          de:
            name: Farbe
        values:
          - label: Navy
            color: '#1f3a5f'
            translations:
              de:
                label: Marineblau
          - Sand

A value with nothing but a label still exports as a bare string, so values: [S, M, L] reads as the list it is.

Two rules govern the file:

  • It records overrides, not resolved text. Exporting resolved text would turn a fallback into a translation on the next import.
  • Import is a merge, not a sync. A language the file names is written; a language it does not name is left alone. This differs from the API's replace-wholesale rule: a German file must not silently delete a store's French.

A translations: block is checked by the same rules as the API's, before anything is written, and the message names the record: Product 'reef-runner-tee': translations.de.titel is not a translatable field; expected one of: …. The one difference is that a file may leave a number unquoted. title: 2024 is a number in YAML, and it is imported as the text "2024".

What a theme needs

Nothing, if the theme uses the plugin's Twig helpers. kahunacart_products(), kahunacart_product() and kahunacart_categories() return rows already resolved for the active language, kahunacart_price() formats in the active locale, and kahunacart_base_route() returns the language-prefixed route.

Three things to check or add:

  • page.metadata in the head. Required. Everything under SEO except the JSON-LD arrives as page metadata, so a hand-assembled head block drops the canonical, og:locale, og:title, og:description and the twitter: tags on every storefront page. Grav's own base templates emit it; a forked theme may have replaced it with something narrower.
  • A language switcher in the site header. Grav has the pieces (grav.language.getLanguages, grav.language.getActive, page.url), but the plugin does not draw one. Storefront URLs are the same path in every language, so a switcher can point at the current path with a different prefix and land on the same product.
  • <html lang="…">. Most themes already emit it from grav.language.getActive. If yours hardcodes en, a German page claims to be English.

Warning

A theme that queries the database directly rather than through the Twig helpers gets base text and no translation resolution.