Skip to content

Powered by Grav

Coming soon — KahunaCart is in final testing. Join the list and be first to know. Join the list

Multilanguage

KahunaCart's catalog lives in a database rather than in files, so it carries its own translations instead of using Grav's per-file ones. 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 the moment the language does.

What can be translated

Entity Fields
Product title, summary, description
Category title, description
Tag label — "Organic", "Bio"
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."

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, which is built from axis and value labels. Those are translatable. kahunacart_variants.title is the older free-text field for a variant outside a matrix.
Zone names, tax classes, shipping classes Back-office labels and keys.
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.

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 a merchant translates 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 are upserted on the unique key through the portable 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 every 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, and 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 is what makes the single-language path a no-op.

The fallback chain is active language → site default language → the base columns on the row. The base columns are not a language, so they are not in the chain.

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 by the time KahunaCart matches its routes, $uri->path() reads /shop/product/foo whichever language was asked for. The plugin therefore 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 and no language 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(), which canonicalizes and stops: 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 derivation is not used, because it produces non-locales such as en_EN and ja_JA.

Note

There is no per-language price. The store has one currency, and only its formatting changes with the locale.

SEO

On a product or category page the plugin emits:

  • a canonical URL carrying the language prefix, so each translation points at itself rather than at the English original;
  • 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 entirely on a single-language site rather than guessed at.

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 is the same treatment the sitemap plugin gives 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. Without both, a store part-way through translating its catalog would have products that are visible on /de and cannot be found 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, with no second query.

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", and the readers treat it that way.

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 inside the order's language: subject line, both templates and the money formatting.

A queue drained from cron has no request, no URL and no 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, so you can see what has been translated without opening every tab. 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.

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 that 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/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}.

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 the moment the file was re-imported.
  • Import is a merge, not a sync. A language the file names is written; a language it does not name is left as it is. This is not the API's replace-wholesale rule: a German file must not silently delete a store's French.

A translations: block naming a field nobody translates fails validation by name, before anything is written.

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.

Two things a theme author may want to add:

  • 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.
  • <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.