Get Started

Version: 0.2.9 Author: Jubayer Hossain โ€” Broodweb License: GPL-2.0-or-later Requires: WordPress 6.0+ ยท PHP 8.1+

BW Multilingual makes any WordPress site multilingual without touching your content. Instead of duplicating posts and pages, it translates the rendered output of your site โ€” theme strings, plugin strings, post content, widgets, menus, form labels, image alt text, meta descriptions and even URL slugs โ€” from a single source language into as many target languages as you need.

Translations can be written by hand in a visual, point-and-click editor, generated automatically by a machine-translation engine (Google Translate, DeepL, Claude or Groq), or any mix of the two.


1. How it works

BW Multilingual sits between WordPress and the browser. Understanding the four stages below explains almost every behaviour in the plugin.

1. Routing. Very early in the request โ€” before WordPress parses the URL โ€” the router inspects the incoming address. If it carries a language marker (/nl/contact/ in subdirectory mode, or ?lang=nl in query mode), the language is recorded for the request and the marker is stripped from the internal request path. WordPress then routes to your ordinary source content, exactly as it would for the untranslated site. Nothing in your database is duplicated.

2. Rendering. WordPress renders the page normally, in your source language.

3. Translation. On secondary-language requests the finished HTML is captured, parsed into a DOM, and every translatable text node and attribute is looked up in the translation store. Matches are replaced; internal links, form actions and canonical tags are re-localized so the visitor stays inside the current language; <html lang> and dir are set; and per-language image replacements are applied.

4. Collection. Any string that had no translation is registered in the database as a source string. This is why the plugin gets more complete the more you browse: a page you have never opened in a second language has not yet contributed its strings. Registered strings then appear in the Translations screen and become eligible for machine translation.

Because translation happens on output, BW Multilingual covers text that other approaches miss โ€” strings hard-coded in a theme, labels printed by a third-party plugin, WooCommerce notices, and text injected via AJAX.

Where translations live

Two custom tables are created on activation:

Table Contents
{prefix}_bwml_strings One row per unique source string: a SHA-1 hash, the original text, its type (dynamic, slug, image, gettext) and an optional context.
{prefix}_bwml_translations One row per (string, language): the translated text, its status (machine, human, reviewed), the provider that produced it, and who last edited it.

Strings are matched by hash, so the same sentence appearing in ten places is translated once.


2. Installation

  1. Upload the bw-multilingual folder to /wp-content/plugins/, or install the ZIP via Plugins โ†’ Add New โ†’ Upload Plugin.
  2. Activate the plugin. On activation it creates its two database tables, seeds default settings (matching your site locale where possible) and flushes rewrite rules.
  3. Open BW Multilingual in the admin menu.

Deactivating the plugin removes its scheduled background work and flushes rewrites but keeps all translations. See Data, uninstalling and privacy.


3. Quick start

  1. BW Multilingual โ†’ Settings โ†’ Languages. Confirm the source language (the language your content is written in), then add one or more target languages and click Save changes.
  2. Visit your site with the new language prefix, for example example.com/nl/. The page renders in your source language for now โ€” but every string on it has just been collected.
  3. Open BW Multilingual โ†’ Visual Editor, pick the language, and click any text on the page to translate it. Or, if you have configured an engine, press โšก Auto-translate page.
  4. Add a language switcher: place the shortcode in a menu, header or widget, or enable the floating switcher under Settings โ†’ SEO & URLs.

Tip: the fastest way to seed a new language is to browse your key pages once with the language prefix, then run Auto-translate remaining from the Translations screen.


4. Settings reference

All configuration lives on a single screen (BW Multilingual โ†’ Settings) split into six tabs. Everything is stored in one option, bwml_settings.

4.1 Languages

Setting Description
Source language The language your content is authored in. Always active. Changing it does not convert existing content.
Site languages The languages visitors can switch to. Each row shows live translation progress, a badge for machine-translated or outstanding strings, and a link into the Translations workbench.
Add language Choose from a catalogue of roughly 130 languages. Newly added languages take effect once you save.

Right-to-left languages (Arabic, Hebrew, Persian, Pashto, Dhivehi, Sorani Kurdish and others) are flagged in the catalogue; the front end automatically sets dir="rtl" on <html> when one is active.

4.2 Translation engines

Setting Description
Translation engine None, Google Translate, DeepL, Claude (Anthropic) or Groq. Powers the โšก buttons and background translation.
Translate automatically When on, newly collected strings are translated in the background as visitors browse.
API keys One field per provider. Keys are write-only in the UI: leave a field blank to keep the key already stored.

Machine output is always saved with the status machine, so you can filter for it and review later.

4.3 SEO & URLs

Setting Description
URL structure Subdirectory (example.com/nl/shop/, recommended) or Query string (example.com/shop/?lang=nl).
Clean default-language URLs When on, the source language keeps the bare domain and only secondary languages get a prefix. Turn it off to serve the default language at /en/ too.
Detect the visitor’s language First-visit auto-redirect โ€” see section 9.
Detection source Country then browser language (recommended), country only, or browser language only.
Shortcode switcher Default style (dropdown or inline) and label format for .
Floating switcher Off, bottom-right or bottom-left, plus its own label format. Independent of the shortcode โ€” both can run at once.

4.4 Appearance

Styling for both switchers, applied as a tiny inline stylesheet with no extra HTTP request.

Setting Range Default
Font size 8โ€“40 px 14 px
Flag size 8โ€“48 px 18 px
Menu background / text colour any hex #ffffff / #182b2f
Menu corner radius 0โ€“40 px 8 px
Menu width 0โ€“600 px (0 = hug content) 0
Pill background / text colour any hex #0e7681 / #ffffff
Pill corner radius 0โ€“100 px 100 (fully rounded)

4.5 Performance

Setting Description
Cache translated pages Recommended. Repeat visits by anonymous users skip DOM parsing and translation entirely.
Cache lifetime 1โ€“168 hours. Default 12.
Clear cache Forces every translated page to rebuild on next visit.

Saving any translation invalidates that language’s cache immediately โ€” you do not need to clear it manually after editing.

4.6 Advanced

A quick reference panel listing the shortcode, the developer hooks, the names of the two database tables and the data-removal constant.


5. Translating your site

There are two editors. They write to the same store, so you can move between them freely.

5.1 Visual Editor

BW Multilingual โ†’ Visual Editor, or the Translate Site button in the admin bar. Opening it from the admin bar while browsing the front end loads that exact page in the editor.

The screen is a live preview of your site in the chosen language, with an editing sidebar.

  • Click any text in the preview to load it into the sidebar. Type the translation and press Save, or use Ctrl/Cmd + Enter.
  • Click any image to replace it for this language only โ€” useful for screenshots, banners with baked-in text, or region-specific imagery. Pick a replacement from the media library; the source stays untouched.
  • โšก Translate sends just the selected string to your engine.
  • โšก Auto-translate page machine-translates every untranslated string on the page currently shown, in batches, with a live progress bar. If the provider reports a rate limit, the editor waits out the suggested delay and resumes automatically.
  • On this page shows counts of strings left, done, and images.
  • SEO panel exposes the page’s <title>, meta description, Open Graph title and description, and each translatable URL slug segment โ€” with an AI-translate button per field and a Save all. Slug values are sanitised to lowercase letters, numbers and hyphens.

Untranslated text is outlined differently from translated text, so gaps are visible at a glance.

5.2 Translations workbench

BW Multilingual โ†’ Translations is a spreadsheet-style list of every collected string, 25 per page.

  • Language tabs switch between target languages.
  • Search matches both originals and translations.
  • Status filters: All ยท Untranslated ยท Machine ยท Human ยท Reviewed.
  • Type filters: All types ยท Text ยท Slugs ยท Images.
  • Per-row โšก machine-translates a single string, overwriting whatever is there.
  • Approve promotes a machine translation to reviewed.
  • Image rows open the media library to choose a replacement.
  • โšก Auto-translate remaining works through the entire untranslated backlog for the active language, batch by batch, with a progress bar.

Translations save on blur or with Ctrl/Cmd + Enter. Inline HTML is permitted in translations and is sanitised on save.

5.3 String statuses

Status Meaning
untranslated Collected but empty.
machine Produced by a translation engine; awaiting review.
human Written or edited by a person.
reviewed Explicitly approved.

6. Machine translation engines

Machine translation is entirely optional โ€” the plugin is fully usable as a manual translation tool. When an engine is configured it powers the โšก buttons, the bulk runs and the optional background translation.

Supported providers

Provider Type Notes
Google Translate Cloud Translation API v2 Broad language coverage. Requires a Google Cloud API key with the Translation API enabled. Sends format=html so inline tags survive.
DeepL DeepL API (free or Pro) Highest quality for European languages. Free keys end in :fx and are routed to the free endpoint automatically. Uses HTML tag handling.
Claude (Anthropic) Messages API AI translation with a structured-output schema, so responses are guaranteed to be well-formed. Best for tone-sensitive marketing copy.
Groq Llama models, OpenAI-compatible API Has a usable free tier. Batches are kept small (8 strings) to fit the free tokens-per-minute budget. Free key available from the Groq console.

The AI providers (Claude and Groq) are prompted as professional website translators and instructed to preserve placeholders (%s, %d, %1$s, {name}, :token) and inline HTML, to leave brand names, URLs and email addresses alone, and to return translations in input order.

Batching, retries and rate limits

Strings are sent in batches โ€” 20 by default, 8 for Groq. When a provider returns HTTP 429, the plugin reads the Retry-After header (or the delay mentioned in the error message), reports it to the caller, and the UI counts down and resumes on its own. Other API errors surface as a readable message; whatever was already translated in that run is kept.

Background auto-translation

With Translate automatically enabled, whenever a page render registers new source strings the plugin schedules a one-off cron event (30 seconds out). That event translates one batch per secondary language and reschedules itself while work remains, so a large backlog drains gradually instead of blocking a page load. Rate limits push the next run further out.

Because this relies on WP-Cron, a site with very little traffic may drain slowly. Sites with a real system cron will be more predictable.

Costs

Every provider except Groq’s free tier bills for usage on your own account. The plugin never sends your API keys anywhere except to the provider you selected. Translating an entire large site in one pass can be expensive โ€” translating page by page from the Visual Editor gives you tighter control.


7. The language switcher

Shortcode



Attribute Values Default
style dropdown, inline from settings
show flag, name, code, flag_name, flag_code from settings

The shortcode works anywhere shortcodes are processed โ€” page content, block editor shortcode blocks, widgets, or in a template via do_shortcode( '

' ).

Floating switcher

Enable it under Settings โ†’ SEO & URLs โ†’ Floating switcher. It renders automatically in the footer of every front-end page as a fixed pill in the bottom-left or bottom-right corner, opening its menu upward. It is configured independently of the shortcode, so you can run both.

Behaviour

  • Each entry links to the current page in that language, not to the home page.
  • Language names are shown as endonyms (“Nederlands”, not “Dutch”).
  • Flags are rendered as SVG images from the WordPress emoji CDN, so they look identical on Windows, which has no flag-emoji font.
  • The switcher is marked translate="no" โ€” its own labels are never translated.
  • The dropdown closes on outside click or Escape, and carries the appropriate ARIA attributes.
  • If only one language is active, the switcher renders nothing.

8. URLs, SEO and sitemaps

URL structure

Subdirectory mode (recommended) puts the language code in the path: example.com/nl/winkel/. This is the mode search engines handle best, and the only mode that supports translated slugs.

Query mode appends ?lang=nl. Paths are left untouched. Useful when your hosting or server configuration makes path prefixes awkward.

With Clean default-language URLs on, the source language has no marker at all; turn it off and it gets its own /en/ prefix like everything else.

Translated slugs

In subdirectory mode, each URL path segment can be translated: /products/ can become /producten/ for Dutch visitors. Slug segments are collected automatically as the plugin localizes internal links, and appear in the Translations screen under the Slugs type filter, or in the Visual Editor’s SEO panel.

Incoming requests are reversed through the same map, so /nl/producten/ resolves to your products page. Numeric segments, filenames and wp-* segments are never treated as slugs. Slug translations are forced to be URL-safe on save.

hreflang

Every front-end page (except 404s) emits <link rel="alternate" hreflang="โ€ฆ"> for each active language, plus an x-default pointing at the source language. Locale codes come from the language catalogue, so pt-br is emitted as pt-BR.

Canonical and Open Graph

On translated pages the canonical link and og:url are rewritten to the current language’s URL, so each language self-canonicalises rather than pointing back at the source.

Per-language sitemaps

Each secondary language serves its own sitemap at /{lang}/sitemap.xml โ€” for example example.com/nl/sitemap.xml. It lists the home page plus all published, public, non-password-protected content (up to 2,000 URLs, ordered by last modified), with localized <loc> values, <lastmod> timestamps and xhtml:link hreflang alternates for every active language.

These sitemaps are advertised automatically in robots.txt, and carry X-Robots-Tag: noindex themselves.

Redirects

Server-side redirects โ€” WooCommerce checkout bounces, add-to-cart, login/logout, form handlers, canonical redirects โ€” are re-localized so visitors stay in their language instead of being dropped back to the source. Admin and external URLs are left alone.


9. Automatic language detection

When Detect the visitor’s language is enabled, a first-time visitor arriving at a URL with no explicit language marker is redirected (HTTP 302) to the same page in their detected language.

Detection sources

  • Country โ€” read from edge or server geo headers (CF-IPCountry from Cloudflare, X-Vercel-IP-Country, X-Country-Code, GEOIP_COUNTRY_CODE), falling back to WooCommerce’s geolocation when WooCommerce is active. No external lookups are ever made, so first requests stay fast.
  • Browser โ€” the Accept-Language header, honouring quality values and falling back from pt-br to pt.
  • Both (recommended) โ€” country first, then browser language.

Guarantees

  • An explicit URL always wins. /en/about/ stays English regardless of detection.
  • The choice is stored in the bwml_lang cookie for one month, so detection runs at most once per visitor and a returning visitor is never bounced again.
  • Only GET requests are considered.
  • Crawlers are never redirected. Search engine bots, link-preview fetchers and headless browsers are detected by user agent and pass through, so every language stays independently indexable.
  • The redirect never fires if it would target the language the visitor is already on.

10. Performance and caching

Two cache layers work together, both versioned per language.

Language map. All translations for a language are loaded into a single cached blob keyed by string hash. When it is warm, rendering an uncached page costs zero translation queries. The map is skipped for very large languages (more than 50,000 translated strings), which fall back to batched lookups.

Page cache. The finished translated HTML is stored per URL and served directly on subsequent visits, skipping DOM parsing entirely. Responses carry an X-BWML-Cache: HIT or MISS header for debugging.

The page cache is deliberately conservative and is never used when:

  • the request is not a GET,
  • the visitor is logged in,
  • DONOTCACHEPAGE is defined,
  • the URL has any query string (except ?lang= in query mode) โ€” this prevents cache poisoning,
  • personalised-state cookies are present (comment author, WooCommerce session, password-protected posts),
  • the page is a WooCommerce cart, checkout or account page.

Invalidation. Saving any translation bumps that language’s cache version, so stale entries become unreachable instantly and expire on their own TTL. The Clear cache button bumps every language at once.

The plugin is compatible with page caches at the host or plugin level; because language URLs are distinct, those caches store each language separately by URL as they normally would.


11. WooCommerce and dynamic content

Translation is not limited to full page loads.

  • HTML fragments โ€” WooCommerce cart fragments and other AJAX partials are translated through the same engine.
  • JSON payloads โ€” wc-ajax and admin-ajax responses are walked recursively; plain string values are looked up, and values containing HTML are run through the fragment translator. URLs, tokens, nonces and path-like values are skipped, and only sentence-like misses are registered as new strings.
  • Referer-based language โ€” AJAX requests carry no language prefix, so the plugin reads the language from the calling page’s referer (same-host only). Requests originating in wp-admin are left untouched, as are the plugin’s own endpoints.
  • Redirect localization keeps WooCommerce flows (empty-cart redirects, add-to-cart, account pages) inside the visitor’s language.
  • Cart, checkout and account pages are excluded from the page cache so they always render live.

12. Excluding content from translation

Some text should never be translated โ€” brand names, code samples, addresses, license keys.

Automatically skipped

  • Content inside <script>, <style>, <noscript>, <code>, <pre>, <kbd>, <samp>, <textarea> and <svg>.
  • The WordPress admin bar.
  • Text with no letters at all (pure numbers, symbols, whitespace).
  • data: image sources.

Manual exclusions โ€” add either of these to any element and its entire subtree is left alone:

<span translate="no">Broodweb</span>
<div class="notranslate">โ€ฆ</div>
<div class="bwml-no-translate">โ€ฆ</div>

Translated attributes are limited to alt, title, placeholder and aria-label, plus the content of <meta name="description">, <meta property="og:title"> and <meta property="og:description">.

Glossary / do-not-translate list. For programmatic control over what reaches your engine, use the bwml_mt_pre_translate filter โ€” returning an empty string for an entry skips it. See the developer reference.


13. Developer reference

Hooks

Hook Type Purpose
bwml_loaded action Fires once the plugin has booted. Receives the BWML_Plugin instance.
bwml_mt_providers filter Register or replace machine-translation engines. Receives slug => BWML_Provider instances.
bwml_mt_pre_translate filter Modify strings immediately before they are sent to a provider. Receives ( array $texts, string $language ). Return an empty string for an entry to skip it.
bwml_claude_model filter Override the Claude model used for translation.
bwml_groq_model filter Override the Groq model used for translation.
bwml_translator_cap filter The capability required to translate. Defaults to manage_options.
bwml_geo_country filter Supply a visitor country code when no geo header is available.
bwml_geo_language filter Override the country โ†’ language mapping. Receives ( string $lang, string $country ).

Examples

Give editors access to the translation screens without full admin rights

add_filter( 'bwml_translator_cap', function () {
    return 'edit_pages';
} );

Never machine-translate your brand names

add_filter( 'bwml_mt_pre_translate', function ( $texts, $language ) {
    $skip = [ 'Broodweb', 'BW Multilingual' ];
    foreach ( $texts as $i => $text ) {
        if ( in_array( trim( $text ), $skip, true ) ) {
            $texts[ $i ] = '';
        }
    }
    return $texts;
}, 10, 2 );

Register a custom translation engine

class My_Provider extends BWML_Provider {
    public function slug() { return 'mine'; }
    public function name() { return 'My Engine'; }
    public function translate( array $texts, $source, $target ) {
        // Return an array of translations in the same order, or a WP_Error.
    }
}

add_filter( 'bwml_mt_providers', function ( $providers ) {
    $providers['mine'] = new My_Provider();
    return $providers;
} );

Detect country from a custom header

add_filter( 'bwml_geo_country', function ( $country ) {
    return strtoupper( $_SERVER['HTTP_X_MY_GEO'] ?? '' ) ?: $country;
} );

Useful classes and methods

Call Returns
BWML_Router::current() The active language code for this request.
BWML_Router::is_default() Whether the request is in the source language.
BWML_Router::localize_url( $url, $lang ) An internal URL rewritten for a language. External, admin and file URLs pass through unchanged.
BWML_Router::current_url_in( $lang ) The current page’s URL in another language.
BWML_Settings::default_language() The source language code.
BWML_Settings::active_languages() All active codes, source first.
BWML_Settings::secondary_languages() Target languages only.
BWML_Languages::all() / ::get( $code ) / ::is_rtl( $code ) The language catalogue.
BWML_Translations::get( $original, $lang, $context = '' ) A translation, or null.
bwml() The BWML_Plugin singleton.

Constants

Constant Meaning
BWML_VERSION, BWML_DB_VERSION Plugin and schema versions.
BWML_DIR, BWML_URL, BWML_FILE, BWML_BASENAME Standard path helpers.
BWML_REMOVE_DATA Define as true in wp-config.php to drop translation tables when the plugin is deleted.

Options and cookies

Key Purpose
bwml_settings All plugin configuration.
bwml_db_version Schema version, used to trigger migrations on update.
bwml_cache_ver Per-language cache version numbers.
bwml_lang (cookie) The visitor’s language, remembered for one month. HTTP-only.

AJAX endpoints

All are nonce-protected and gated by the translator capability.

bwml_load_strings ยท bwml_save_translation ยท bwml_get_string ยท bwml_seo_data ยท bwml_machine_translate ยท bwml_machine_translate_page


14. Data, uninstalling and privacy

Deactivating clears scheduled background translation and flushes rewrite rules. All translations remain.

Deleting the plugin removes its options, scheduled events and cached transients. Translation tables are preserved by default, because translations are your work. To remove them too, add this to wp-config.php before deleting:

define( 'BWML_REMOVE_DATA', true );

Privacy. The plugin stores a single first-party cookie (bwml_lang) recording the visitor’s language choice. Country detection uses headers your own server or CDN already provides โ€” no external IP lookups are performed. Text is sent to a third-party translation API only when you have configured an engine, and only the strings being translated are transmitted. If you enable a translation engine, mention it in your privacy policy.


15. Troubleshooting

Language URLs return 404. Rewrite rules need flushing. Visit Settings โ†’ Permalinks and click Save, or deactivate and reactivate the plugin.

No strings appear in the Translations screen. Strings are collected as pages are rendered in a secondary language. Browse the pages you want to translate with the language prefix (example.com/nl/โ€ฆ) once, then reload the Translations screen.

A translation was saved but the page still shows the original. Saving invalidates the cache automatically, but an external page cache (host-level, CDN or a caching plugin) may still be serving the old copy. Purge it. To confirm which layer is responsible, check the X-BWML-Cache response header.

Machine translation fails immediately. Check the API key, that the relevant API is enabled on the provider account, and that the account has quota. The error message shown in the UI is passed through from the provider and usually names the cause. Note that DeepL free keys must end in :fx.

Auto-translation stalls or runs very slowly. Background translation uses WP-Cron, which only fires on site traffic. On low-traffic sites, either translate on demand from the editors or configure a real system cron.

Some text is never translated. It is probably inside a skipped element (<script>, <pre>, <svg>โ€ฆ), inside an element marked translate="no" or .notranslate, rendered inside an iframe, or injected by JavaScript after the page has loaded. Text drawn into a <canvas> or baked into an image cannot be translated โ€” use the per-language image replacement for those.

The switcher does not appear. It renders nothing when fewer than two languages are active. Confirm a second language is saved.

Translated slugs are not applied. Slug translation only works in subdirectory URL mode.

Layout breaks in Arabic or Hebrew. The plugin sets dir="rtl" on <html>; your theme must supply the corresponding RTL styles.


16. FAQ

Does it duplicate my posts and pages? No. There is one copy of your content. Translations are stored separately and applied to the rendered output, which means no bloated post tables and no duplicate content to maintain.

Do I have to use machine translation? No. Set the engine to None and translate entirely by hand in the Visual Editor or the Translations workbench.

Can I mix machine and human translation? Yes โ€” that is the intended workflow. Machine-translate in bulk to get coverage quickly, then filter by the Machine status and refine the strings that matter, marking them Reviewed as you go.

Will search engines index the translated pages? Yes. Each language has its own crawlable URL, its own canonical, hreflang alternates on every page, and its own XML sitemap advertised in robots.txt. Crawlers are never redirected by language detection.

Does it work with page builders? Yes. Because translation happens on the rendered HTML, output from Elementor, Divi, Bricks, Gutenberg and others is handled the same way as any other markup.

Does it work with WooCommerce? Yes, including cart fragments and AJAX responses. Cart, checkout and account pages are excluded from the page cache so they always render live.

How many languages can I add? As many as you need, from a catalogue of roughly 130. Each additional language increases collected string volume and cache size, so add the ones you will actually maintain.

Can I translate images? Yes. Any <img> can be given a per-language replacement, chosen from the media library in either editor. Useful for graphics with embedded text.

Can a non-administrator translate? Yes, via the bwml_translator_cap filter. See the developer reference.

Is it compatible with my caching plugin? Yes. Language URLs are distinct, so external caches store each language separately as they would any other URL.


BW Multilingual is developed and maintained by Broodweb.

Was this article helpful to you?

Newsletter

Stay in the loop

Get notified about new plugins, updates, and exclusive tutorials.

No spam ever. Unsubscribe anytime.