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.
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 |
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) 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, and pricing identical across all language variants of a product. Fires on woocommerce_process_product_meta after each product save.
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. 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 every non-order change uses, which is why a group that has drifted re-converges on the next admin, Quick Edit, REST or WP-CLI write.
What else triggers a sync
Stock is only part of it. These are the save paths that push shared data to the other language copies:
| 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 |
Quick Edit and Bulk Edit matter more than they look: both save through WooCommerce’s CRUD and never fire woocommerce_process_product_meta, so without those two hooks a price or SKU changed from the products list would diverge across languages while stock quietly kept syncing.
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).
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.
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. 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.
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.
- 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. Since 1.0.3 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 sent 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.
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.
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 since 1.0.3 they render in the shop’s language rather than the order’s, so a store taking orders from three countries no longer receives its own notifications in three languages. 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 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. 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 used to 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 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 left the request inconsistent with subdirectory routing and the Store API returned an HTML 404 instead of JSON — a broken cart. Since 1.0.1 the cookie step is skipped on those sites, so 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.
Behavior Notes
- No double-selling: Inventory sync runs at priority 100 on
woocommerce_process_product_meta, after WooCommerce has saved all product data. - Recursion guard: InventorySync tracks in-progress syncs in a static array to prevent circular updates between language variants.
- 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.