WooCommerce Guide
Deep, performant integration with WooCommerce - translates products, categories, attributes, and emails. Syncs inventory across language variants. Supports per-language currencies.
How It Works
The WooCommerce addon activates automatically when WooCommerce is installed and active. No manual registration is needed.
WooCommerce’s Shop Manager role gets the perflocale_translate capability once, so shop managers can create product translations (see Permissions).
What Gets Translated
| Content | Translated |
|---|---|
| Product title + content | Yes |
| Product short description (excerpt) | Yes |
Product purchase note (_purchase_note) | Yes |
External product button text (_button_text) | Yes |
Variation description (_variation_description) | Yes |
Product categories (product_cat) | Yes |
Product tags (product_tag) | Yes |
Product attributes (pa_*) | Yes (Dynamic) |
| Product attribute values | Yes |
| Product image alt text | Yes (Via Media Translation) |
| Product gallery alt text | Yes (Via Media Translation) |
What Stays Shared (Not Translated)
| Field | Why Shared |
|---|---|
| Stock / Stock Status | Same physical product |
| SKU | Unique identifier across all languages |
| Price / Sale Price | Kept in sync; use currency table for conversion |
| Weight / Dimensions | Physical product properties |
| Virtual / Downloadable | Product nature, not language-dependent |
| Total Sales counter | Aggregated across all languages |
When a product translation is created
A new product translation takes the source’s catalog settings: its catalog visibility (shown everywhere, shop only, search only or hidden), whether it is featured, its shipping class and its brand. When your site makes the shipping-class or brand taxonomy translatable, the translation gets that language’s terms instead. The out-of-stock and rating markers follow the translation’s own stock and rating. Changing these settings later on one language is not copied to the others.
A new simple, grouped, external or other non-variable product translation also gets its row in WooCommerce’s product lookup table, so it sorts by price, matches the price and stock filters and is found by SKU search from the start; a variable product’s row is rebuilt with its variations. WooCommerce → Status → Tools → Product lookup tables → Regenerate builds the row of any product translation that has none. On WooCommerce older than 10.8, run it once if some product translations are missing from price sorting or the price and stock filters, or a search by their SKU does not find them.
Orders
shop_order is not a translatable post type. Orders are language-tagged using the _perflocale_language meta key, set when the order is placed whenever the WooCommerce addon is active (whether or not order emails are translated), but are never duplicated per language. This ensures inventory and reporting remain accurate.
Settings
Located at PerfLocale → Settings → Addons → WooCommerce. The subtab appears only when WooCommerce is active and the WooCommerce addon hasn’t been disabled on the PerfLocale → Addons screen.
Inventory Sync
Keep stock, SKU, GTIN, pricing, weight and dimensions identical across all language variants of a product. Every save of a product or variation through WooCommerce is covered - the product editor, Quick Edit, the REST API, the CSV importer, orders, and code in a theme or another plugin. See what triggers a sync for what each one sends.
Order Email Language
Send order confirmation, processing, completed, and other status emails in the language the customer used when placing the order. The language is stored on the order as _perflocale_language meta whether or not this setting is on (see The language an order is recorded in); the setting decides whether the emails use it. This covers the customer-facing order emails; the three notifications addressed to the store - new_order, cancelled_order and failed_order - are deliberately left in the shop’s language (see Order emails).
Email Subject & Heading Translation
WooCommerce email subjects, headings, and additional content are registered as translatable strings when you click Scan for Strings or save WC email settings. They appear on the PerfLocale Strings page under domain woocommerce with contexts like email_subject_customer_processing_order.
When the source text changes (e.g., you edit the subject in WooCommerce → Settings → Emails), PerfLocale migrates the old translation to the new string with a Needs Update status so translators know to review it. The old translation text is preserved as a starting point.
Custom email types can be added via the perflocale/woocommerce/translatable_email_ids filter (see Hooks documentation).
Translating Email Body Templates
PerfLocale translates email subject and heading through the PerfLocale Strings system (your own translations, entered on the Strings page). The email body template is rendered by WooCommerce itself using standard __() / _e() calls. For emails addressed to the customer, PerfLocale switches the WordPress locale before the body renders - anchored on the per-email subject filter, so plain-text templates are covered too - and holds it until the message has actually been sent, so headers, attachments and the plain-text half of a multipart email are all produced in the same language. WooCommerce’s own translation files take over for the body text.
However, switch_to_locale() only has an effect if the corresponding locale’s .mo files are actually installed on the server. If they’re missing, WooCommerce falls back to English - the body will appear untranslated even though the subject and heading are correctly in the target language.
Install language packs from the WordPress admin via Settings → General → Site Language (WordPress core) and WooCommerce → Status → Tools, or via WP-CLI:
# Install WordPress core + all active plugins' language packs for German and French.
wp language core install de_DE fr_FR
wp language plugin install --all de_DE fr_FR
# For a specific plugin only:
wp language plugin install woocommerce de_DE fr_FRHow to verify on your site: wp language core list --status=installed should show every locale you target with PerfLocale. If a language is listed in PerfLocale but missing from that output, order emails in that language will render subjects correctly but bodies will appear in English.
Per-Language Currency
Display product prices converted to a language-specific currency. Configure the currency code and exchange rate per language. Prices are converted at display time - all transactions use WooCommerce's default base currency.
Currency auto-detected per language. When you add a new language, the Exchange Rates table pre-fills its currency code from the language’s locale - pl_PL → PLN, en_GB → GBP, ja_JP → JPY, pt_BR → BRL, and so on across a table of country codes plus a fallback for bare language codes. You can still override any row before saving. Without this the table would fall back to the WooCommerce base currency for every new row, so on a European store a freshly-added Polish would land on EUR until fixed by hand.
Automatic Exchange Rate Sync
Refresh the per-language rates on a scheduled interval, with a “Sync Now” button for manual runs. No rate provider ships with the plugin - the store supplies rates either by registering one through the perflocale/woocommerce/exchange_rate_providers filter or by returning them directly from perflocale/woocommerce/exchange_rates_fetched. Until one of those is wired up, the settings screen says so and no request is made to any rate service. See the Exchange Rates documentation for both seams.
Product Attributes
All registered WooCommerce attributes (pa_* taxonomies) are automatically discovered and registered as translatable. New attributes created in WooCommerce → Attributes are included without any configuration.
URLs
All WooCommerce pages include the language prefix automatically:
/de/shop/- product archive/de/cart/- cart/de/checkout/- checkout/de/my-account/- account/de/my-account/orders/- order history/de/my-account/downloads/- downloads
Stock across languages
Every language copy of a product is a separate post with its own _stock row, so keeping them equal is a real synchronisation problem rather than a display trick. PerfLocale treats order-driven stock changes differently from every other kind of edit, and the difference is what stops a busy multilingual store overselling.
How an order moves stock
WooCommerce never writes a new stock figure when an order is placed. It issues a relative statement - subtract one from whatever is currently stored - precisely so two shoppers cannot overwrite each other. PerfLocale observes that statement on woocommerce_update_product_stock_query at priority 1 (read-only: the query is returned untouched), records the signed delta, and replays the same relative change against each sibling language - one statement per sibling that adds the delta to whatever that row holds at the moment it runs. The database applies it as a single operation.
That gives the answer merchants ask for before switching: simultaneous orders in different languages all land. Three shoppers buying the same stock-10 product through the English, German and Polish product pages at the same moment leave every copy at 7. Mirroring an absolute snapshot instead - reading the bought product’s new quantity and writing that number onto its siblings - is a read-modify-write on rows that each have their own value, and it silently loses concurrent decrements.
After a relative write, each sibling’s _stock_status is re-derived from its own resulting quantity rather than copied from the product that was bought. The rule is WooCommerce’s own, from WC_Product::validate_props(): above the woocommerce_notify_no_stock_amount threshold is in stock, otherwise on backorder when backorders are allowed, otherwise out of stock. A product that does not manage its own stock keeps the status you set by hand. The sibling’s wc_product_meta_lookup row - the derived table behind catalogue stock filters, price sorting and SKU search - is refreshed in the same pass, so shop queries see the new figure instead of a stale one.
Refunds and cancellations restock the same way: WooCommerce issues an increase, and the increase is mirrored.
Two cases fall back to copying an absolute figure: a sibling that has no numeric _stock value of its own (there is nothing to decrement, and a blind subtraction would drive it negative), and a stock statement another plugin has rewritten into a shape PerfLocale does not recognise. The fallback takes a lock and copies the source’s quantity and status. It is also the path an explicit stock change uses: setting a new quantity in the product editor, Quick Edit, the REST API, WP-CLI or code copies that quantity to the other language copies. A save that leaves the quantity unchanged moves no stock, so saving the same number again does not re-align a copy whose stock has drifted: align it or set its quantity explicitly.
Checking that language copies hold the same stock
wp perflocale stock-drift reads every product translation group and lists the groups whose copies hold different stock quantities: products that manage their own stock, and each published or private variation with its matching variation in every other language. Managed stock that allows backorders is compared too, negative quantities included. Copies that do not manage their stock, including products only marked On backorder, copies excluded from the sync (Independent across languages, or the perflocale/woocommerce/skip_inventory_sync filter) and products in the Trash are left out. Stock held by pending orders is not counted: the command compares the stored quantity. On its own it changes nothing. See WP-CLI for its options and exit status.
Checking the stock of language copies
With Inventory Sync on, PerfLocale → Settings → Addons → WooCommerce has a Stock across languages row for users with manage_woocommerce. Check stock lists each product or variation whose language copies hold different stock, with the stock per language. A large check runs as a background job and is listed on the Jobs page too. Aligning the copies’ stock is not available in this version: set the stock of a copy as on any product.
Shared stock before payment
WooCommerce holds stock for an order that is still being paid for, but it counts that hold only against the product the order contains. Without help, a unit held by a German checkout would still look free on the English product. With Inventory Sync on, PerfLocale counts the holds of every language copy wherever WooCommerce checks what is left: the cart and checkout checks, adding to the cart, the checkout’s own hold, and pay-for-order. The hold rows themselves are unchanged, so release, expiry and refunds work as WooCommerce designed them.
The same applies to a cart that holds two language copies of one item, for example after the shopper switched language. The classic cart and checkout, the block Cart and Checkout, and adding to the cart check the quantities of all copies together against the shared stock, and refuse with WooCommerce’s own notice when it is not enough. When every copy of a product is set to Sold individually, the copies count as one product, so a cart cannot hold it twice in two languages. In the block Cart and Checkout, the notice for that names the product in the page language.
A copy counts when it manages its own stock and is not opted out of sync (see Turning sync off for one product). For a variation, the copies are the matching variations of the parent’s language copies.
To check a variation, PerfLocale first finds the matching variation of each language copy. It stores that match per variation for up to a day and checks it against the products on every read, so a cart or checkout stock check, and the stock sync of the order placed after it, run fewer database queries; stock quantities, held stock, stock management and the per-product opt-out are always read live and never stored. Two filters cover the stored matches: perflocale/woocommerce/stock_map_enabled switches them off, and perflocale/woocommerce/stock_map_settle_seconds sets how long PerfLocale waits after a change before it stores new ones. After changing translation links of attribute terms directly in the database, use Clear cache, so stock checks and stock sync use the new links.
The quantity limit shown in Store API product data - product blocks and the Store API products route - is WooCommerce’s own and costs no extra query. Only the cart and the steps after it count every copy. To count them in product data too, return true from perflocale/woocommerce/count_shared_holds_in_catalog.
PerfLocale adds no lock of its own around the hold. In our tests with MySQL’s default isolation level (REPEATABLE READ), two checkouts in different languages that reached the last unit at the same moment never both got a hold. With the database set to READ COMMITTED, they sometimes did.
What else triggers a sync
Stock is only part of it. Every save of a product or variation through WooCommerce pushes shared data to the other language copies. The screens and APIs in the table sync every shared field once their own save is done; a save from anywhere else sends the fields that save changed.
| Where the change is made | Hook | What is mirrored |
|---|---|---|
| Product edit screen | woocommerce_process_product_meta (priority 100) | All shared fields |
| Products list Quick Edit / Bulk Edit | woocommerce_product_quick_edit_save, woocommerce_product_bulk_edit_save | All shared fields |
| Order, cancellation, refund | woocommerce_product_set_stock, woocommerce_variation_set_stock | _stock and _stock_status only |
| WooCommerce REST API product write | woocommerce_rest_insert_product_object | All shared fields |
| Variation saved (admin panel or REST) | woocommerce_save_product_variation, woocommerce_rest_insert_product_variation_object | Variation fields, then a rollup of each sibling parent |
| Variations tab bulk action | woocommerce_bulk_edit_variations | Variation fields; sibling parents rolled up once for the whole run |
| CSV importer row (product or variation) | woocommerce_product_import_inserted_product_object | All shared fields, unless perflocale/woocommerce/sync_on_import returns false |
Any other save of an existing product or variation: code in a theme or plugin, a feed or ERP connector, wc_update_product_stock_status() | woocommerce_product_object_updated_props, then woocommerce_update_product / woocommerce_update_product_variation | The shared fields that save changed; for a variation, each sibling parent is rolled up once at the end of the request |
A copy that manages its own stock keeps its quantity when these screens and APIs copy the shared fields: only the stock changes in the table move it (orders, cancellations and refunds as a relative change, and a save that sets a new quantity). A sale that lands on a copy while the product is being saved, for example by an ERP connector over the REST API, is therefore never overwritten. A copy that does not manage its own stock, or that the save stops managing, takes the saved product’s quantity.
For a save from code, the trigger is the list of props WooCommerce reports as changed by that save:
- A change to the regular price, the sale price or the sale dates sends the whole price group, because WooCommerce rewrites
_priceand can clear_sale_pricealong with it. - SKU, GTIN, weight, length, width, height, virtual, downloadable, stock management and backorders each send their own key.
- Stock quantity is never sent this way. The stock hooks in the table carry it, as a relative change for orders.
- Stock status is sent only from a product that does not manage its own stock, and only to copies that do not manage theirs. A copy that manages its own stock keeps the status its own quantity gives it, and works that status out again whenever the source’s stock management, backorders or stock status change.
- A variable or grouped product’s prices and stock status come from its children, so they are never sent.
- Creating a product or variation sends nothing through this trigger; its first update does.
- A value written straight to post meta with
update_post_meta()is not a WooCommerce save and is not seen.
The per-product opt-out, Price Sync, perflocale/woocommerce/inventory_sync_enabled and the field filters apply to this trigger exactly as to the paths above it. When one of those paths saves the product or variation it is working on, the path’s own sync covers that save, so nothing is synced twice and perflocale/woocommerce/sync_on_import still decides for imports. Any other product saved while one of those paths runs, such as a linked product a plugin updates from the path’s hooks, is synced too, at the latest when the path has finished. While a translation is being created, a save of the new copy pushes nothing back to the product it was made from. The stock save WooCommerce makes during a sale is left to the stock hooks, so checkout does no extra work.
With Price Sync on, a group has one price. If code writes a different price to each language copy, the last save wins in every language. To keep language-specific prices, switch Price Sync off or opt the product out.
Quick Edit and Bulk Edit save through WooCommerce’s CRUD and never fire woocommerce_process_product_meta; their own hooks sync every shared field after the edit. Setting “In stock?” on a variable product whose variations do not manage stock also reaches the matching variations in the other languages, and each sibling parent is rolled up once.
Fields kept identical
The default list is _stock, _stock_status, _manage_stock, _backorders, _price, _regular_price, _sale_price, _sale_price_dates_from, _sale_price_dates_to, _sku, _global_unique_id, _weight, _length, _width, _height, _virtual and _downloadable. Turning off Price Sync drops the five price keys from that list and leaves everything else syncing. The list itself is filterable through perflocale/woocommerce/synced_product_fields (products) and perflocale/woocommerce/synced_variation_fields (variations). A custom key added there travels with the screens and APIs in the table above; a save from code sends only the WooCommerce fields it changed.
Two deliberate exclusions are worth knowing about. total_sales is not synced: the sync overwrites rather than adds, so copying one copy’s counter onto its siblings would wipe their own sales history and skew best-seller reports - each language copy keeps its own count. And on a variable parent the price keys are skipped, because WooCommerce derives them from the children (it stores one _price row per distinct child price to build the min–max range); the sibling parent’s aggregates are rebuilt from its own variations instead.
Variations and attributes
Matching variations across languages
Variations are not linked into translation groups. A product_variation post inherits its parent’s language, so there is no direct German counterpart to look up for an English variation. PerfLocale anchors on the parent product - which is group-linked - and then matches children by their exact attribute set.
The match is made on the translation-group identity of each attribute term, not on the raw slug. That is what lets English pa_color=red and German pa_color=rot resolve to the same variation: PerfLocale deliberately rewrites a cloned variation’s attribute slugs into the target language (without it the variation dropdown renders empty and the translated product cannot be bought), so the raw signatures never agree and only the canonical form does. Custom, non-taxonomy attributes, “Any…” entries stored as an empty value, and terms with no translation group keep their literal value and are compared as-is.
If a translator has changed a sibling’s attributes so the sets no longer line up, that variation is skipped rather than guessed at. Stock and price for that one variation stop propagating until the attributes match again - a visible gap is safer than writing a price onto the wrong colour.
When variation data does move, each touched sibling parent is rolled up with WC_Product_Variable::sync() afterwards, so the sibling’s price range, aggregate stock status and price-range transients are rebuilt from its own children instead of staying stale until someone opens and saves it.
Variations on a new translation
Translating a variable product clones its variations onto the new copy: attributes (remapped to the target language’s sibling terms, in lock-step with the parent’s attribute options), regular and sale price, the sale schedule, stock and backorder settings, weight and dimensions, tax class, per-variation shipping class, menu order, image, and the variation description as a seed for the translator. Downloadable variations carry their file list, download limit and expiry, so a customer buying the translated variation actually receives the files. SKUs are copied verbatim. The step is idempotent - a translation that already has variation children is left alone - and the parent’s price range and lookup row are rebuilt at the end.
Machine translation of variations and custom attributes
When a product is machine-translated (on publish, on Create, in bulk, with WP-CLI or through the REST API), PerfLocale also translates its variation descriptions and the options of its custom attributes: the attributes typed on the product itself, not the global pa_* attributes, whose terms are translated as terms. This runs whether or not meta fields were included, and it counts against the monthly character limit.
- A value is translated only while the translation still holds the original’s copy, so text a translator changed is kept. A field listed in Sync Fields, or emptied on the translation, is not translated.
- Translated options are written to the product and, in step, to each of its variations. The translation records which translated option stands for which original option, so stock and price sync keep pairing the variations across languages.
- An attribute keeps its original options when the translated ones would be empty or repeat one another, or when a variation uses a value that is not one of its options.
- A failure never fails the product’s translation. The reason (“Variation descriptions or attribute options were not translated: …”) is stored on the translation with the other meta-field errors and passed to
perflocale/mt/meta_translate_failed. - The character estimate shown before a bulk run does not include these texts; the monthly limit is checked for them when they are sent.
Shared SKUs and GTINs
Translation siblings are the same physical item, so they share a SKU. WooCommerce rejects duplicate SKUs, and duplicate GTIN/UPC/EAN values (_global_unique_id, WooCommerce 9.1 and newer), so PerfLocale exempts siblings from both checks through wc_product_has_unique_sku and wc_product_has_global_unique_id.
The exemption is narrow on purpose. When WooCommerce reports a conflict, PerfLocale looks up every other product or variation holding that value and only overrides the rejection when all of them are translation siblings of the product being saved (or variations whose parents are). A genuine third-party duplicate - a real data-entry mistake - is still rejected exactly as WooCommerce intended, and so is a conflict PerfLocale cannot explain, such as one where the lookup table has no row for the other holder yet.
Turning sync off for one product
Sometimes one market needs its own price: a promotion that runs only in German, or a language-specific bundle. Tick Independent across languages on the product’s Product data → Advanced panel. The checkbox writes the meta key _perflocale_sync_optout with the value yes, and it appears only while Inventory Sync is switched on.
The flag lives on a single language copy, and it blocks traffic in both directions: an opted-out product neither receives its siblings’ data nor pushes its own out on save. So ticking it on the German product alone gives German independent prices and stock while English and Polish carry on syncing with each other. Variations inherit their parent’s flag, and a single variation can carry the flag itself. The flag is deliberately not one of the synced fields, so it never propagates to the other copies.
Because an unticked checkbox is indistinguishable from a checkbox that was never rendered, the field ships a marker input alongside it - programmatic saves through the REST API, the CSV importer or WP-CLI cannot silently clear a flag they never showed.
Setting it outside the editor needs nothing more than core WP-CLI:
# Give one language copy independent prices and stock.
wp post meta update <product_id> _perflocale_sync_optout yes
# Put it back under sync.
wp post meta delete <product_id> _perflocale_sync_optoutFor a whole class of products, use perflocale/woocommerce/skip_inventory_sync (documented below), which receives the per-product flag as its default value. The store-wide switches can be read and changed from the command line too:
wp perflocale addon settings get woocommerce wc_sync_stock
wp perflocale addon settings set woocommerce wc_sync_prices false --type=boolCurrency, cart and checkout
What gets converted
With Per-Language Currency enabled, conversion is not cosmetic. PerfLocale filters woocommerce_currency on every non-admin request, so WooCommerce treats the language’s currency as the active store currency: cart totals, the amount handed to the gateway and the currency recorded against the order are all the converted ones. Check that every gateway you use accepts every currency you enable. The admin is deliberately left on the base currency, because that is the currency you type prices into.
| Converted | Left in the base currency |
|---|---|
| Product price, regular price, sale price | Percentage coupons (they scale with the already-converted line totals) |
| Variation prices, including WooCommerce’s cached variation price range | Cart fees added programmatically by other plugins |
| Shipping rate costs and their tax amounts | Everything shown in wp-admin |
| Fixed-cart and fixed-product coupon amounts | |
| Coupon minimum and maximum spend thresholds |
The spend thresholds matter more than they look: WooCommerce validates them in the base currency, so an unconverted “minimum spend 50” would be compared against a cart total already displayed and charged in another currency, and would reject perfectly valid coupons. Programmatically added cart fees are the honest gap - there is no accumulation-safe filter to scale them, so they stay in the base currency.
Converted figures are rounded to WooCommerce’s configured decimal places, except that zero-decimal currencies (JPY, KRW, VND and the rest of the ISO 4217 zero-minor-unit set) are forced to whole numbers, so a converted price renders as ¥2609 rather than a fractional amount. A product price or shipping cost above zero never converts to zero: when it would round to zero, it becomes the smallest unit at those decimal places (1 with no decimals, 0.01 with two), so an item cannot become free through rounding. Fixed coupon amounts convert without that floor, so a discount cannot take that smallest unit off a floored price. Each language row also chooses whether to show the symbol or the currency code, and where to put it.
A language priced in the store’s own base currency is pinned to a rate of 1.0 whatever is stored against it, so a leftover rate from before that row was switched to the base cannot silently mark every price up or down. And when an automatic sync returns only some of the currencies you asked for - a provider’s free tier, a per-symbol quota, a temporarily unquoted currency - the rates it did not cover are carried forward rather than dropped, but only while they are still provably valid: same store base currency, and the same currency code still configured for that language.
A rate must be a number above zero. The settings screen does not store any other value: the row keeps its stored rate, or gets 1.0 when its currency changed. A stored or synced rate that is not a number above zero is not used.
Cart and mini-cart
Switching language does not empty the cart. The items stay; what changes is how they are shown and priced:
- Totals are recalculated the first time the language differs from the one recorded in the WooCommerce session, so the mini-cart cannot keep showing the previous currency until someone opens the cart page.
- Each cart line’s name and permalink are mapped to the current language’s copy of that product. A translation that is still a draft is not used - unreleased copy should not appear to someone holding an item in their basket - and the line keeps the source label with a link that stays inside the current language. The name is the copy’s stored title, so a password-protected product shows no “Protected:” prefix.
- The block Cart, Mini-Cart and Checkout read the cart through the Store API. For those cart responses, including the cart embedded in the checkout response, each line’s name, short description and image also come from the current language’s published copy; the image is swapped only when that copy has one of its own. Price, quantity and stock stay those of the product in the cart; the name stored on the order line is described under Order line names.
- Mini-cart fragments cached in the browser’s
sessionStorageare keyed per language, and stale keys left by other languages are cleared on the front end, so the mini-cart cannot serve English HTML on a German page. - The
wc-ajaxendpoint carries the language - a path prefix in subdirectory mode,?lang=in query mode - so a fragment refresh resolves the language from the URL instead of falling back to a cookie that may point somewhere else.
Coupons, cross-sells and upsells
Cross-sell and upsell IDs are copied verbatim onto a translation, which would otherwise render default-language products on a German product page. They are swapped for the current language’s published sibling at read time; anything untranslated passes through unchanged.
Coupon restriction lists get the same treatment in reverse: a coupon limited to specific products or product categories has those lists expanded with every translation sibling when it is evaluated on the front end, so a coupon created against the English product also applies on the German cart, and an exclusion list cannot be side-stepped by adding the translated product instead. The expansion is read-only - the stored coupon and its edit screen keep exactly the IDs you saved.
The language an order is recorded in
As Orders notes above, an order carries the shopper’s language in the _perflocale_language order meta key rather than being duplicated per language. Working out which language to record is the interesting part.
Detection prefers the router’s current language, then falls back to the perflocale_lang cookie when the router resolved to nothing or to the default language and the cookie names a different, currently active language. That fallback covers the cases where no language appears in the path at all: the block checkout, the Store API at /wp-json/wc/store/v1/checkout, and the classic checkout’s admin-ajax.php POST. It is deliberately not used for an order a shop manager creates by hand in wp-admin - there the cookie is the staff member’s own browsing language, not the buyer’s, and tagging the order with it would send the customer’s emails in the wrong language. The cookie value is validated against the active language map, so an order is never tagged with a language you have deactivated.
An order that has no language yet - typically one the shop created in wp-admin, through the REST API or with WP-CLI and sent to the customer for payment - takes the language of the page it is paid on: the classic order-pay page (/de/checkout/order-pay/…) or the Store API order-pay and checkout routes. The language is stored before the payment sends any email, so the customer’s emails for that order use it. An order that already has a language keeps it.
An order’s personal-data export (Tools → Export Personal Data, WooCommerce’s order section) includes an Order language row with the language’s name.
Order line names
When a customer checks out in a language other than the default, each variation’s order line is named as the cart showed it: the product’s title with the attribute values in that language. Lines of other product types keep WooCommerce’s own name, and nothing changes when no attribute value has a translation in that language. Order screens and emails hide an attribute row whose value, in any of its languages, is already part of the line name. Showing a variation in another language never changes its stored title.
Order emails
When an email for that order renders, PerfLocale switches two things to the order’s language and restores both afterwards: the WordPress locale, so WooCommerce’s own template strings come from that language’s translation files, and PerfLocale’s current language, so term names, attribute labels and string translations resolve in the order’s language even when the email is triggered from wp-admin, a gateway webhook or cron. The switch is anchored on the subject filter rather than the email header, which is why plain-text emails - whose templates never fire woocommerce_email_header - are covered as well. If a render dies mid-template, a shutdown-time restore puts the site locale back, so a long-lived PHP worker cannot leak the customer’s language into the next request.
Each email keeps its own language: when one email’s render stops before its footer, the next email still renders in its own language, and a stock notification sent after it in the shop’s language. With Per-Language Currency on, prices in an order email are converted at the rate of the email’s language, and the request’s own rate applies again afterwards.
An order without a language (one placed before PerfLocale ran, or imported without one) sends its customer emails in the default language, whoever triggers them. When the shop kept WooCommerce’s default subject, heading or additional content and no translation is stored for it, WooCommerce’s default text is written in the order’s language even when an administrator, a scheduled task or WP-CLI sends the email, and the {order_date} placeholder is formatted in that language too. Text you typed yourself is left as it is.
These email IDs are handled by default: new_order, cancelled_order, failed_order, customer_on_hold_order, customer_processing_order, customer_completed_order, customer_refunded_order, customer_invoice and customer_note.
The first three go to you, not the customer. They are still handled - PerfLocale hooks them - but they render in the shop’s language (the default language) rather than the order’s, whatever language the request that sends them is in, so a store taking orders from three countries receives its own notifications in one language. WooCommerce’s low-stock, out-of-stock and backorder notifications are rendered in the default language as well. The test is WooCommerce’s own WC_Email::is_customer_email(), the same one core gates its locale switch on. If you run per-language fulfilment staff and genuinely want an admin notification in the buyer’s language, return true for it from perflocale/woocommerce/email_uses_order_language. The two filters compose: perflocale/woocommerce/translatable_email_ids chooses which emails are handled, this one chooses whose language they use.
Store pages
Creating the translated pages
Under PerfLocale → Settings → Addons → WooCommerce, the Pages section has a Create Page Translations button. It creates translation stubs for Cart, Checkout, My Account and Shop in every active language other than the default, titling each one from your installed WordPress and WooCommerce translations for that language first and from machine translation only if that is configured and the local lookup found nothing; when neither produces a title, the source title is kept. Machine-translated titles count against the monthly character limit, and a title sent once is not sent again unless the original title changed. A language whose WordPress language pack is not installed has nothing to look up locally, so it goes straight to the next step rather than borrowing a title in your own admin language. Existing translations are never overwritten, so re-running it is safe - a second click reports the pages as already existing rather than creating duplicates. At least two active languages are required.
The created pages keep the source page’s slug, so you get /de/cart/ until you edit that page’s slug yourself. Translating the slug is an ordinary page edit.
Links and conditional tags
The part that quietly breaks in naive setups is not the URL, it is the conditional tags. WooCommerce decides whether you are on the cart by comparing the current page against the ID stored in woocommerce_cart_page_id, which holds the default language’s page, so on a translated page is_cart() returns false and the template, blocks and body classes all behave as though it were an ordinary page. PerfLocale filters those page-ID options - cart, checkout, myaccount, shop and terms - to the current language’s translated ID on the front end, so is_cart(), is_checkout() and is_account_page() answer correctly.
Links are covered on two levels. Cart, checkout, shop and My Account permalinks, plus account endpoint URLs such as /my-account/orders/, carry the language through WooCommerce’s own URL filters. Parts of WooCommerce bypass those entirely and build a href straight from get_permalink() - the Mini-Cart block’s cart and checkout buttons, and the checkout’s terms-and-conditions link - which would otherwise hand a shopper on /de/ an unprefixed cart URL and drop them back into the default language mid-funnel. Those are corrected at the page_link layer, on the front end only, and only for WooCommerce pages that have no translation of their own; a page you have actually translated keeps its own URL. Admin screens, REST responses and sitemap output are excluded, so a <loc> still advertises an untranslated page under its own language rather than under a language it does not exist in.
Store API responses and caches
Block themes and WooCommerce blocks load products, the cart and the checkout from the Store API at /wp-json/wc/store/. That address carries no language, so PerfLocale takes the shopper’s language from the perflocale_lang cookie, and the same URL can return German for one visitor and English for another. The cart and checkout routes already tell caches not to store them. The catalogue routes (products, categories, attributes, reviews) send no cache headers of their own, so PerfLocale marks a GET or HEAD response whose non-default language came from the cookie as not cacheable, with WordPress’s no-cache headers. A default-language cookie gives the same answer as no cookie, so those responses stay cacheable.
If a page cache or CDN in front of the site stores /wp-json/wc/store/, one case is still open: a cached default-language response can be served to a visitor whose cookie asks for another language. Choose one of these:
- Bypass the cache for
/wp-json/wc/store/. - Add the
perflocale_langcookie to the cache key for that path. - If your cache honours
Vary, returntruefromperflocale/router/store_api_vary_cookie, and PerfLocale addsVary: Cookieto those responses. It is off by default because it lowers the hit rate for every visitor who carries any cookie, and not every cache honours it (Cloudflare’s does not).
Store API on a subdirectory network
Block Cart and Block Checkout talk to the WooCommerce Store API. That API is reached at a fixed path relative to the site root, so PerfLocale resolves the shopper’s language for those requests from the language cookie rather than from the URL - a Store API call carries no language prefix of its own, and without the cookie a German shopper would get English stock, coupon and checkout messages.
On a subdirectory multisite child the site root already carries a path segment of its own (/shop/wp-json/wc/store/…), which that resolution does not account for. Resolving a non-default language there would leave the request inconsistent with subdirectory routing, and the Store API would return an HTML 404 instead of JSON - a broken cart. So the cookie step is skipped on those sites: the endpoint always works and Store API strings come back in the site’s default language.
This affects only subdirectory multisite children. Single sites, subdomain networks, per-domain networks and the network’s own root blog resolve the shopper’s language normally. Translated product content, prices and currency are unaffected everywhere - this is about the language of Store API messages, not about what is sold.
Developer Hooks
Filters
perflocale/woocommerce/synced_product_fields
Control which meta keys are synced across language variants.
add_filter( 'perflocale/woocommerce/synced_product_fields', function ( array $fields, int $product_id ): array {
// Add a custom field to the sync list.
$fields[] = '_my_custom_field';
// Or remove a field if you want per-language pricing.
$fields = array_diff( $fields, [ '_price', '_regular_price', '_sale_price' ] );
return $fields;
}, 10, 2 );Parameters:
array $fields- Default list of meta keys.int $product_id- ID of the product being saved.
perflocale/woocommerce/skip_inventory_sync
Prevent inventory sync for a specific product.
add_filter( 'perflocale/woocommerce/skip_inventory_sync', function ( bool $skip, int $product_id ): bool {
// Skip sync for products in a specific category.
if ( has_term( 'digital', 'product_cat', $product_id ) ) {
return true;
}
return $skip;
}, 10, 2 );perflocale/woocommerce/email_uses_order_language
Decide whether one order email is rendered in the order’s language or the shop’s. The default is WooCommerce’s own WC_Email::is_customer_email(), so customer emails follow the order and the three admin notifications stay in the shop language. The answer governs the whole render: an email that does not use the order’s language gets no locale switch and no String Translation lookup, so its subject, heading and additional content stay in the shop language too.
// Per-language fulfilment: send the admin new-order email in the order language.
add_filter( 'perflocale/woocommerce/email_uses_order_language', function ( bool $uses, $email, $order ): bool {
return $email->id === 'new_order' ? true : $uses;
}, 10, 3 );perflocale/woocommerce/translate_string
Override translation for a WooCommerce gateway title, description, or shipping label.
add_filter( 'perflocale/woocommerce/translate_string', function ( ?string $translated, string $text, string $slug, string $context ): ?string {
if ( $slug === 'de' && $text === 'Direct bank transfer' ) {
return 'Direktüberweisung';
}
return $translated; // Return null to fall through to default lookup.
}, 10, 4 );Parameters:
string|null $translated- Null (no override yet) or a translated string.string $text- Original text.string $slug- Current language slug.string $context- Gateway ID or empty string.
Actions
perflocale/woocommerce/inventory_synced
Fires after inventory sync completes successfully.
add_action( 'perflocale/woocommerce/inventory_synced', function ( int $product_id, array $synced_ids, array $fields ): void {
error_log( "Synced product #{$product_id} to: " . implode( ', ', $synced_ids ) );
}, 10, 3 );Parameters:
int $product_id- Source product that was saved.int[] $synced_ids- Language variant IDs that received the update.string[] $fields- Meta keys that were synced. For a save from code, only the keys that save changed.
Behavior Notes
- No double-selling: an order’s stock change is replayed on each sibling as the same relative change, and the save WooCommerce makes during the sale never copies a quantity, so each sale lands once in every language.
- Recursion guard: a sync that copies values takes a short per-product lock (an options row) and marks itself as writing, so a save its own writes cause never starts another sync.
- Locale switching:
LanguageRouter::filter_locale()overrides the WordPress locale to match the detected language. All WooCommerce UI strings using__()are automatically served in the correct language. - Cache: WooCommerce product transients are cleared for each sibling after sync (
wc_delete_product_transients). - REST API: Inventory sync also fires on
woocommerce_rest_insert_product_objectso bulk edits via the REST API are covered.