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.supportedlists the languages the site runs in. - Migration
0012_translationshas been applied, either by auto-migrate or bybin/plugin kahunacart migrate. ext-intlis installed, if you want locale-aware price formatting. Without it,Money::format()falls back toUSD 28.00in every language.
Turn on the multilanguage catalog
-
Add the languages to
user/config/system.yaml:YAMLlanguages: supported: [en, de] -
Run
bin/grav clearcache. -
Run
bin/plugin kahunacart migrateif your store does not auto-migrate. -
Translate a product from the admin, or
PATCHone through the API. -
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:
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
inLanguageon theProductand theBreadcrumbList, and with the translated name, description and breadcrumb names; og:locale, plus the translatedog:titleandog: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:
<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
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
- Open the product in the admin and go to the Details panel.
- Click the language tab you want, next to Base (en).
- Enter the overrides. Each field shows the base text as its placeholder, and a "falls back to the base" note when it is empty.
- 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:
{
"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.
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:
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 fromgrav.language.getActive. If yours hardcodesen, 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.
Related
- Storefront — routes, templates and Twig functions
- Theming — custom properties and the class reference
- Products and catalog — the catalog the translations sit on