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.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 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:
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
inLanguageon theProductand theBreadcrumbList, and with the translated name, description and breadcrumb names; og:locale, plus the translatedog:titleandog: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:
<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
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
- 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. 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:
{
"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.
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.
nullreads as empty, and empty clears that override. A number, a boolean, a list or an object is refused, not converted.
{"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:
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.metadatain 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:descriptionand thetwitter: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 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
- Translating KahunaCart — the plugin's own words in a second language