WordPress Internationalization at Scale: Automated Translation Pipelines and Performant i18n
Shipping a WordPress product to a global audience means thinking about internationalization (i18n) from the earliest commit. A single-language plugin or theme might work fine for a domestic market, but the moment you target users in Germany, Japan, Brazil, or Egypt, you need a system that handles string extraction, translation file generation, machine translation seeding, human review, right-to-left layout support, and performance optimization across dozens of locales. This article walks through every layer of that system, from the file formats that store translations to the CI pipelines that keep them current.
The techniques here apply to plugin developers shipping on WordPress.org, theme authors distributing through marketplaces, and agencies managing multilingual client sites with WooCommerce catalogs spanning thousands of products. Whether you maintain three languages or thirty, the architecture remains the same. The scale changes, but the principles do not.
WordPress Translation File Formats: .po, .mo, and .l10n.php
WordPress has used GNU gettext translation files since its earliest versions. Understanding the three file formats that carry translations through the system is necessary before building any automation around them.
PO Files: The Human-Readable Source
A .po (Portable Object) file is a plain-text file that pairs original English strings with their translations. Each entry consists of a msgid (the source string) and a msgstr (the translated string), along with optional translator comments and source code references.
# Translator comment explaining context
#: src/class-checkout.php:142
msgid "Your cart is empty."
msgstr "Ihr Warenkorb ist leer."
#: src/class-checkout.php:200
msgid "%d item in your cart"
msgid_plural "%d items in your cart"
msgstr[0] "%d Artikel in Ihrem Warenkorb"
msgstr[1] "%d Artikel in Ihrem Warenkorb"
PO files also carry a header block specifying the language, plural forms expression, charset, and metadata about the last translator. The plural forms header is critical because languages differ wildly in how they handle plurals. English has two forms (singular and plural). Arabic has six. Polish has three. The Plural-Forms header tells WordPress which msgstr index to select at runtime:
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
Tooling like Poedit, Loco Translate, or any text editor can open PO files directly. They are version-control friendly because they are plain text, and diffs remain readable when translations change.
MO Files: The Binary Runtime Format
A .mo (Machine Object) file is the compiled binary counterpart of a PO file. WordPress loads MO files at runtime using the load_textdomain() function. The binary format enables fast hash-table lookups instead of parsing text on every page load.
You generate MO files from PO files using the msgfmt command-line tool (part of GNU gettext) or through Poedit’s save function:
msgfmt -o languages/my-plugin-de_DE.mo languages/my-plugin-de_DE.po
MO files follow a strict naming convention: {text-domain}-{locale}.mo. For a plugin with text domain my-plugin targeting German, the file is my-plugin-de_DE.mo. WordPress looks for these files in three locations, checked in order: WP_LANG_DIR/plugins/, the path registered with load_plugin_textdomain(), and the plugin’s own directory.
.l10n.php: The PHP Translation Format
WordPress 6.5 introduced a PHP-based translation format that outperforms MO files significantly. A .l10n.php file returns a PHP array of translations that the opcode cache (OPcache) can store in memory, eliminating file I/O on subsequent requests entirely.
<?php
return array(
'translation-revision-date' => '2024-03-10 08:22:14+0000',
'messages' => array(
'Your cart is empty.' => 'Ihr Warenkorb ist leer.',
'%d item in your cart' . "\0" . '%d items in your cart' => '%d Artikel in Ihrem Warenkorb' . "\0" . '%d Artikel in Ihrem Warenkorb',
),
);
The performance gains are substantial. Benchmarks on sites loading 15+ text domains show a 30-50% reduction in translation loading time compared to MO files. The PHP format benefits from OPcache’s shared memory model, meaning that after the first request compiles the file, every subsequent request reads translations directly from shared memory without touching the filesystem.
WordPress.org now generates .l10n.php files automatically for plugins and themes hosted in the directory. If you distribute outside WordPress.org, you can generate them yourself using the WP-CLI i18n command:
wp i18n make-php languages/my-plugin-de_DE.po languages/
At runtime, WordPress checks for the .l10n.php file first. If it exists, the MO file is never loaded. This means you can ship both formats for backward compatibility without any performance penalty on modern WordPress installations.
GlotPress: Setup, Administration, and Translation Workflow
GlotPress is the open-source translation management system that powers translate.wordpress.org. Running your own GlotPress instance gives you full control over the translation workflow for proprietary plugins, themes, or client projects that cannot go through the public WordPress.org translation system.
Installing and Configuring GlotPress
GlotPress runs as a WordPress plugin on a dedicated WordPress installation (or a subsite in a multisite network). Install it via Composer or download from GitHub:
composer require glotpress/glotpress
# Or clone directly
git clone https://github.com/GlotPress/GlotPress.git wp-content/plugins/glotpress
After activation, GlotPress registers custom post types and database tables for projects, translation sets, translations, and glossaries. Configure the permalink structure in Settings to ensure GlotPress URLs work properly. The plugin mounts its interface at /glotpress/ by default.
Create a project structure that mirrors your product lineup. Each project gets a name, slug, and optional parent for hierarchical organization:
/glotpress/projects/my-plugin/
/glotpress/projects/my-plugin/stable/
/glotpress/projects/my-plugin/dev/
/glotpress/projects/my-theme/
/glotpress/projects/my-theme/2.x/
User Roles and Permissions
GlotPress defines four translation roles: Translator, Validator, Project Admin, and Admin. Translators can suggest translations that enter a “waiting” state. Validators (also called locale managers) can approve or reject suggestions. This two-step workflow prevents low-quality translations from reaching production without review.
Assign validators per locale, not globally. Your German validator should not need to approve Japanese translations. GlotPress supports this through per-translation-set permissions. Set them programmatically if you manage many locales:
// Grant validator rights for a specific translation set
$permission = new GP_Permission();
$permission->user_id = $user_id;
$permission->action = 'approve';
$permission->object_type = 'translation-set';
$permission->object_id = $translation_set_id;
$permission->save();
Importing and Exporting Strings
Feed GlotPress with a POT (Portable Object Template) file. This is a PO file without translations, containing only the source strings. Generate it from your plugin or theme source code using WP-CLI:
wp i18n make-pot . languages/my-plugin.pot --slug=my-plugin --domain=my-plugin --exclude=node_modules,vendor,tests
Upload the POT file through the GlotPress web interface or use the REST API for automation. When you upload a new POT file, GlotPress compares it against existing strings. New strings are added with empty translations. Removed strings are marked as obsolete but not deleted, preserving translation history.
Export completed translations as PO, MO, or Android/iOS formats. The GlotPress API endpoint for export follows this pattern:
GET /glotpress/projects/my-plugin/stable/de/default/export-translations/?format=mo
GET /glotpress/projects/my-plugin/stable/de/default/export-translations/?format=po
Automating GlotPress with the REST API
GlotPress exposes a JSON API that lets you integrate translation management into your deployment pipeline. Use it to check translation completeness before releasing a new version:
#!/bin/bash
# Check translation status for all locales
LOCALES=("de" "fr" "es" "ja" "pt-br" "ar" "zh-cn")
THRESHOLD=90
for locale in "${LOCALES[@]}"; do
response=$(curl -s "https://translate.example.com/api/projects/my-plugin/stable/${locale}/default")
percent=$(echo "$response" | jq '.percent_translated')
if (( $(echo "$percent < $THRESHOLD" | bc -l) )); then
echo "WARNING: ${locale} is only ${percent}% translated"
fi
done
Machine Translation Integration: DeepL and Google Translate API
Machine translation has improved dramatically in recent years, and using it as a first pass before human review can accelerate translation workflows by 60-70%. The key principle: machine translation is a starting point, never a final output. Automated translations go into a "fuzzy" or "waiting" state and require human validation before approval.
Building a DeepL Integration
DeepL consistently outperforms other machine translation engines for European languages. Their API is straightforward to integrate. Here is a PHP class that translates PO file strings through DeepL and writes the results back:
class WPKite_DeepL_Translator {
private string $api_key;
private string $api_url = 'https://api-free.deepl.com/v2/translate';
public function __construct( string $api_key ) {
$this->api_key = $api_key;
}
/**
* Translate an array of strings, preserving printf placeholders.
*
* @param array $strings Source strings to translate.
* @param string $target_lang DeepL language code (DE, FR, ES, etc.).
* @return array Translated strings keyed by original.
*/
public function translate_batch( array $strings, string $target_lang ): array {
// Protect printf-style placeholders from translation
$protected = array();
$placeholder_map = array();
foreach ( $strings as $index => $string ) {
$placeholders = array();
$protected_string = preg_replace_callback(
'/%(\d+\$)?[+-]?(?:0|\'[^$])?-?\d*(?:\.\d+)?[bcdeEfFgGhHosuxX%]/',
function( $match ) use ( &$placeholders ) {
$token = '{{PH' . count( $placeholders ) . '}}';
$placeholders[] = $match[0];
return $token;
},
$string
);
$protected[ $index ] = $protected_string;
$placeholder_map[ $index ] = $placeholders;
}
// Send batch request to DeepL
$response = wp_remote_post( $this->api_url, array(
'body' => array(
'auth_key' => $this->api_key,
'text' => array_values( $protected ),
'target_lang' => strtoupper( $target_lang ),
'tag_handling' => 'xml',
'preserve_formatting' => '1',
),
'timeout' => 30,
) );
if ( is_wp_error( $response ) ) {
return array();
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$results = array();
foreach ( $body['translations'] as $i => $translation ) {
$translated = $translation['text'];
// Restore placeholders
foreach ( $placeholder_map[ $i ] as $j => $original_placeholder ) {
$translated = str_replace( '{{PH' . $j . '}}', $original_placeholder, $translated );
}
$results[ $strings[ $i ] ] = $translated;
}
return $results;
}
}
The placeholder protection step is critical. Without it, DeepL will mangle %s, %d, and %1$s placeholders, producing translations that cause PHP warnings or display raw format specifiers to users. Wrapping placeholders in XML-like tokens and using DeepL's tag_handling option keeps them intact.
Google Cloud Translation API
Google Cloud Translation API v3 (Advanced) offers similar functionality with broader language coverage. It supports 130+ languages compared to DeepL's 30+, making it the better choice for Southeast Asian, African, and indigenous languages.
use Google\Cloud\Translate\V3\TranslationServiceClient;
function wpkite_google_translate_strings( array $strings, string $target_lang ): array {
$client = new TranslationServiceClient();
$project_id = 'my-project-id';
$location = 'global';
$parent = sprintf(
'projects/%s/locations/%s',
$project_id,
$location
);
$response = $client->translateText(
$strings,
$target_lang,
$parent,
[
'sourceLanguageCode' => 'en',
'mimeType' => 'text/plain',
]
);
$results = array();
foreach ( $response->getTranslations() as $i => $translation ) {
$results[ $strings[ $i ] ] = $translation->getTranslatedText();
}
$client->close();
return $results;
}
Feeding Machine Translations into GlotPress
The bridge between your machine translation engine and GlotPress is a script that reads untranslated strings from GlotPress, sends them through the translation API, and writes them back as "fuzzy" suggestions. Here is the workflow:
#!/bin/bash
# 1. Export untranslated strings from GlotPress
curl -s "https://translate.example.com/api/projects/my-plugin/stable/de/default?status=untranslated" \
-o untranslated-de.json
# 2. Run through DeepL via custom PHP script
php translate-via-deepl.php untranslated-de.json de > translated-de.json
# 3. Import as fuzzy/waiting translations
php import-to-glotpress.php translated-de.json --status=fuzzy --project=my-plugin --locale=de
Human validators then review these suggestions in the GlotPress interface, approving accurate translations and correcting those that miss context or tone. This hybrid approach lets a single validator process 200-300 strings per hour instead of translating from scratch at 40-60 strings per hour.
Translation Memory and Glossary Management
Translation memory (TM) stores previously approved translations and suggests them when identical or similar strings appear in new versions or different projects. Glossaries enforce consistent terminology across all translations. Both are essential when managing translations at scale.
Building a Translation Memory System
GlotPress has basic translation memory built in. When a translator works on a string, GlotPress searches for identical or similar strings that have already been translated in other projects. This surfaces previous translations as suggestions.
For a more capable TM system, store all approved translations in a dedicated database table indexed for fast fuzzy matching:
CREATE TABLE wp_translation_memory (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
source_text TEXT NOT NULL,
source_hash CHAR(32) NOT NULL,
translated_text TEXT NOT NULL,
locale VARCHAR(10) NOT NULL,
domain VARCHAR(100) NOT NULL,
quality_score TINYINT UNSIGNED DEFAULT 100,
last_used DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_hash_locale (source_hash, locale),
INDEX idx_locale_domain (locale, domain),
FULLTEXT INDEX idx_source_fulltext (source_text)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
The source_hash column (an MD5 of the normalized source string) enables exact-match lookups in constant time. The full-text index supports fuzzy matching for strings that are similar but not identical. When a new POT file arrives with strings that partially match existing translations, the TM system can suggest starting points that save translators significant effort.
Query the TM with both exact and fuzzy strategies:
function wpkite_find_translation_memory( string $source, string $locale ): array {
global $wpdb;
$hash = md5( strtolower( trim( $source ) ) );
// Try exact match first
$exact = $wpdb->get_row( $wpdb->prepare(
"SELECT translated_text, quality_score
FROM {$wpdb->prefix}translation_memory
WHERE source_hash = %s AND locale = %s
ORDER BY quality_score DESC, last_used DESC
LIMIT 1",
$hash,
$locale
) );
if ( $exact ) {
return array(
'translation' => $exact->translated_text,
'match_type' => 'exact',
'score' => (int) $exact->quality_score,
);
}
// Fall back to fuzzy full-text search
$fuzzy = $wpdb->get_row( $wpdb->prepare(
"SELECT translated_text, quality_score,
MATCH(source_text) AGAINST(%s IN NATURAL LANGUAGE MODE) AS relevance
FROM {$wpdb->prefix}translation_memory
WHERE locale = %s
HAVING relevance > 5
ORDER BY relevance DESC
LIMIT 1",
$source,
$locale
) );
if ( $fuzzy ) {
return array(
'translation' => $fuzzy->translated_text,
'match_type' => 'fuzzy',
'score' => (int) $fuzzy->quality_score,
);
}
return array();
}
Glossary Enforcement
A glossary defines how specific terms must (or must not) be translated. For a WordPress product, your glossary might specify that "dashboard" stays as "Dashboard" in German (because the German WordPress community uses the English term), while "plugin" becomes "Erweiterung" or remains "Plugin" depending on your project's convention.
GlotPress includes a glossary feature tied to each locale. Define entries with the source term, translation, part of speech, and optional comments. Validators see glossary warnings when a suggested translation uses a term differently from the glossary.
For programmatic glossary enforcement during automated translation, run a post-processing check:
function wpkite_enforce_glossary( string $translation, string $locale ): array {
$glossary = wpkite_get_glossary( $locale ); // Returns array of term => required_translation
$violations = array();
foreach ( $glossary as $source_term => $required_translation ) {
// Check if the source term's concept appears but uses wrong translation
if ( stripos( $translation, $source_term ) !== false
&& stripos( $translation, $required_translation ) === false ) {
$violations[] = sprintf(
'Term "%s" should be translated as "%s"',
$source_term,
$required_translation
);
}
}
return $violations;
}
Surface glossary violations in your CI pipeline or GlotPress review interface so validators can catch them before approval. Over time, a well-maintained glossary eliminates the most common translation inconsistencies and reduces review cycles.
Continuous Localization: Extracting Strings from CI Builds
Manual string extraction and POT file uploads do not scale past a few releases per year. Continuous localization integrates string extraction into your CI/CD pipeline so that every merge to your main branch automatically updates the translation source and notifies translators of new or changed strings.
CI Pipeline Architecture
The pipeline has four stages: extract, compare, upload, and notify. Here is a GitHub Actions workflow that implements all four:
name: Continuous Localization
on:
push:
branches: [main]
paths:
- 'src/**/*.php'
- 'src/**/*.js'
- 'src/**/*.jsx'
jobs:
extract-and-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install WP-CLI
run: |
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar
sudo mv wp-cli.phar /usr/local/bin/wp
- name: Install i18n command
run: wp package install wp-cli/i18n-command
- name: Extract strings from PHP and JS
run: |
wp i18n make-pot . languages/my-plugin.pot \
--slug=my-plugin \
--domain=my-plugin \
--exclude=node_modules,vendor,tests,build \
--include=src/,templates/ \
--headers='{"Report-Msgid-Bugs-To":"[email protected]"}'
- name: Compare with previous POT
id: diff
run: |
# Download previous POT from artifact or S3
aws s3 cp s3://translations-bucket/my-plugin.pot languages/my-plugin-previous.pot || true
if [ -f languages/my-plugin-previous.pot ]; then
NEW_STRINGS=$(diff <(grep "^msgid " languages/my-plugin-previous.pot | sort) \
<(grep "^msgid " languages/my-plugin.pot | sort) \
| grep "^>" | wc -l)
REMOVED_STRINGS=$(diff <(grep "^msgid " languages/my-plugin-previous.pot | sort) \
<(grep "^msgid " languages/my-plugin.pot | sort) \
| grep "^<" | wc -l)
echo "new_count=${NEW_STRINGS}" >> $GITHUB_OUTPUT
echo "removed_count=${REMOVED_STRINGS}" >> $GITHUB_OUTPUT
else
TOTAL=$(grep -c "^msgid " languages/my-plugin.pot)
echo "new_count=${TOTAL}" >> $GITHUB_OUTPUT
echo "removed_count=0" >> $GITHUB_OUTPUT
fi
- name: Upload POT to GlotPress
if: steps.diff.outputs.new_count > 0 || steps.diff.outputs.removed_count > 0
run: |
curl -X POST "https://translate.example.com/api/projects/my-plugin/stable/import-originals" \
-H "Authorization: Bearer ${{ secrets.GLOTPRESS_API_TOKEN }}" \
-F "pot=@languages/my-plugin.pot"
- name: Store current POT
run: aws s3 cp languages/my-plugin.pot s3://translations-bucket/my-plugin.pot
- name: Notify translators
if: steps.diff.outputs.new_count > 0
run: |
curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
-H "Content-Type: application/json" \
-d "{\"text\":\"New strings available for translation: ${{ steps.diff.outputs.new_count }} added, ${{ steps.diff.outputs.removed_count }} removed. Review at https://translate.example.com/projects/my-plugin/stable/\"}"
String Extraction Best Practices
The wp i18n make-pot command scans PHP files for calls to WordPress translation functions: __(), _e(), _n(), _x(), esc_html__(), esc_attr__(), and their variants. It also scans JavaScript files for wp.i18n.__() and related functions. Several practices ensure clean extraction:
First, always use string literals as the first argument to translation functions. Variable interpolation or concatenation inside the function call prevents the extractor from finding the string:
// WRONG: extractor cannot find this string
__( $dynamic_string, 'my-plugin' );
__( 'Hello ' . $name, 'my-plugin' );
// RIGHT: use sprintf for dynamic content
sprintf( __( 'Hello %s', 'my-plugin' ), $name );
Second, provide translator context using _x() when a word has multiple meanings. The word "post" in English can mean a blog post or the verb "to post." Without context, translators guess:
// Ambiguous
__( 'Post', 'my-plugin' );
// Clear context for translators
_x( 'Post', 'noun: a blog entry', 'my-plugin' );
_x( 'Post', 'verb: to publish', 'my-plugin' );
Third, use translator comments (prefixed with translators:) for strings that need additional explanation. The extractor picks these up and includes them in the POT file:
/* translators: %1$s is the site name, %2$d is the number of active users */
$message = sprintf(
__( '%1$s has %2$d active users this month.', 'my-plugin' ),
$site_name,
$active_count
);
Handling String Freezes and Release Branches
For major releases, implement a "string freeze" period where no new translatable strings enter the codebase. This gives translators time to complete their work before release. Enforce it with a CI check on pull requests targeting the release branch:
- name: Enforce string freeze
if: github.base_ref == 'release/3.0'
run: |
wp i18n make-pot . /tmp/new.pot --domain=my-plugin
NEW=$(diff <(grep "^msgid " languages/my-plugin.pot | sort) \
<(grep "^msgid " /tmp/new.pot | sort) | grep "^>" | wc -l)
if [ "$NEW" -gt 0 ]; then
echo "::error::String freeze is active. $NEW new strings detected. No new translatable strings allowed on the release branch."
exit 1
fi
JavaScript i18n in Blocks: wp.i18n and JSON Translation Files
Block editor development in React/JSX requires a separate i18n pipeline from PHP. WordPress provides the @wordpress/i18n package for JavaScript translations and uses JSON-based translation files (JED format) instead of MO files.
Using wp.i18n in Block Code
Import translation functions from the @wordpress/i18n package in your block's JavaScript:
import { __, _n, _x, sprintf } from '@wordpress/i18n';
function PricingBlock( { itemCount, currency } ) {
return (
<div className="pricing-block">
<h3>{ __( 'Your Order', 'my-plugin' ) }</h3>
<p>
{ sprintf(
_n(
'%d item in your cart',
'%d items in your cart',
itemCount,
'my-plugin'
),
itemCount
) }
</p>
<p>
{ sprintf(
/* translators: %s is the currency symbol (e.g., $, EUR) */
_x( 'Currency: %s', 'payment currency label', 'my-plugin' ),
currency
) }
</p>
</div>
);
}
The function signatures mirror their PHP counterparts exactly, so translators work with the same string format regardless of whether the string appears in PHP or JavaScript.
Generating JSON Translation Files
WordPress loads JavaScript translations from JSON files, not MO files. Generate them from your PO files using WP-CLI:
wp i18n make-json languages/ --no-purge
This command reads each PO file in the languages/ directory, extracts only the strings that come from JavaScript source files, and writes them to JSON files named with an MD5 hash of the relative source file path:
languages/my-plugin-de_DE-a1b2c3d4e5f6.json
The JSON file follows the JED 1.x format:
{
"translation-revision-date": "2024-03-10 08:22:14+0000",
"generator": "WP-CLI/2.9.0",
"source": "src/blocks/pricing/index.js",
"domain": "my-plugin",
"locale_data": {
"my-plugin": {
"": {
"domain": "my-plugin",
"lang": "de",
"plural-forms": "nplurals=2; plural=(n != 1);"
},
"Your Order": ["Ihre Bestellung"],
"%d item in your cart\u0004%d items in your cart": [
"%d Artikel in Ihrem Warenkorb",
"%d Artikel in Ihrem Warenkorb"
]
}
}
}
Registering JavaScript Translations in PHP
After enqueuing your block's script, call wp_set_script_translations() to tell WordPress where to find the JSON translation files:
function my_plugin_register_block() {
$asset_file = include plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
wp_register_script(
'my-plugin-pricing-block',
plugins_url( 'build/index.js', __FILE__ ),
$asset_file['dependencies'],
$asset_file['version']
);
wp_set_script_translations(
'my-plugin-pricing-block',
'my-plugin',
plugin_dir_path( __FILE__ ) . 'languages'
);
register_block_type( 'my-plugin/pricing', array(
'editor_script' => 'my-plugin-pricing-block',
) );
}
add_action( 'init', 'my_plugin_register_block' );
WordPress uses the script handle to determine which JSON file to load. It calculates the MD5 hash of the script's relative path and looks for a matching JSON file in the specified languages directory. If your build process changes the output filename, the JSON file hash will no longer match, and translations will silently fail. Pin your output filenames or regenerate JSON files after each build.
Script Module Translation Loading
WordPress 6.5+ introduced script modules (ES modules loaded via wp_register_script_module()). Translation loading for script modules works differently. Instead of the hash-based JSON filename approach, script modules use the wp_script_module_translations filter. Register translations for script modules with:
wp_register_script_module(
'my-plugin/frontend',
plugins_url( 'build/frontend.js', __FILE__ ),
array( '@wordpress/interactivity' ),
'1.0.0'
);
// Script module translations are loaded via the textdomain
// WordPress resolves the JSON file automatically when the module loads
RTL Support: Testing and Debugging Bidirectional Layouts
Right-to-left (RTL) languages like Arabic, Hebrew, Farsi, and Urdu require mirrored layouts where text flows from right to left, navigation items reverse, and directional UI elements (arrows, progress bars, sliders) flip horizontally. Supporting RTL properly goes beyond CSS transforms and requires intentional design decisions throughout your theme or plugin.
WordPress RTL Infrastructure
WordPress automatically loads RTL stylesheets when the active locale is an RTL language. For every style.css, WordPress looks for style-rtl.css in the same directory. If found, it replaces the LTR stylesheet entirely.
When you enqueue a stylesheet, WordPress handles RTL switching automatically if you follow the naming convention:
// WordPress will automatically load my-plugin-rtl.css for RTL locales
wp_enqueue_style(
'my-plugin-styles',
plugins_url( 'css/my-plugin.css', __FILE__ ),
array(),
'1.0.0'
);
// You can also add the RTL stylesheet explicitly
wp_style_add_data( 'my-plugin-styles', 'rtl', 'replace' );
The wp_style_add_data() call with 'rtl' and 'replace' tells WordPress to swap the entire stylesheet for RTL locales. Alternatively, use 'suffix' to append -rtl to the filename automatically.
Generating RTL Stylesheets
Manually maintaining separate LTR and RTL stylesheets is error-prone. Use RTLCSS (a Node.js tool) to generate RTL stylesheets automatically from your LTR source:
npm install rtlcss --save-dev
Add it to your build pipeline:
// postcss.config.js for LTR
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
};
// Generate RTL version via CLI
npx rtlcss dist/style.css dist/style-rtl.css
RTLCSS flips left to right, margin-left to margin-right, padding-left to padding-right, and transforms values in shorthand properties. It also flips border-radius corners, background-position, and transform: translateX() values.
Control RTLCSS behavior with directive comments when certain properties should not flip:
/* rtl:ignore */
.logo {
margin-left: 20px; /* Stays margin-left in RTL */
}
/* rtl:raw:
.special-element {
direction: rtl;
text-align: right;
float: right;
}
*/
CSS Logical Properties for Bidirectional Layouts
Modern CSS logical properties eliminate the need for separate RTL stylesheets entirely. Instead of physical properties like margin-left and padding-right, use logical equivalents that adapt to the document's text direction:
/* Physical properties (need RTL override) */
.card {
margin-left: 1rem;
padding-right: 2rem;
border-left: 3px solid blue;
text-align: left;
}
/* Logical properties (automatically correct in both LTR and RTL) */
.card {
margin-inline-start: 1rem;
padding-inline-end: 2rem;
border-inline-start: 3px solid blue;
text-align: start;
}
Tailwind CSS 3.3+ supports logical properties through the ms- (margin-start), me- (margin-end), ps- (padding-start), and pe- (padding-end) utilities. If your project uses Tailwind, prefer these over ml-, mr-, pl-, and pr-.
Testing RTL Layouts
Test RTL without changing the site language by adding dir="rtl" to the HTML element in your browser's dev tools. For systematic testing, create a simple mu-plugin that forces RTL mode via a query parameter:
<?php
/**
* Plugin Name: RTL Debug Helper
* Description: Add ?rtl=1 to any URL to test RTL layout.
*/
add_filter( 'locale', function( $locale ) {
if ( isset( $_GET['rtl'] ) && $_GET['rtl'] === '1' ) {
return 'ar'; // Arabic triggers RTL mode
}
return $locale;
} );
Common RTL bugs to watch for during testing:
Icons that contain directional arrows (back arrows, forward arrows, reply icons) need to flip in RTL. Use the CSS transform: scaleX(-1) on these icons when body.rtl is present. Breadcrumb separators should also reverse: use ‹ instead of › in RTL contexts.
Absolute positioning breaks frequently in RTL. An element positioned with right: 10px should become left: 10px in RTL. Logical properties (inset-inline-end: 10px) solve this, but older code often uses physical positioning that must be audited manually.
Form inputs with text-align: left look wrong in RTL locales. Number inputs and email fields are exceptions that should remain LTR even in an RTL context because email addresses and numbers read left-to-right universally. Use the dir attribute on individual inputs when needed:
<input type="email" dir="ltr" placeholder="[email protected]" />
<input type="tel" dir="ltr" placeholder="+1 (555) 123-4567" />
Performance Profiling: Measuring i18n Overhead and Lazy-Loading Text Domains
Translation loading is not free. On a WordPress site with 20 active plugins, each loading its own text domain, the i18n subsystem can add 50-200ms to page generation time. Profiling this overhead and reducing it is essential for sites that prioritize performance.
Measuring Translation Loading Time
Use the Query Monitor plugin to see which text domains are loaded and how long each takes. Query Monitor's "Languages" panel shows every load_textdomain() call, the file loaded, and the time consumed.
For programmatic profiling, hook into the translation loading process:
add_action( 'load_textdomain', function( $domain, $mofile ) {
if ( ! isset( $GLOBALS['wpkite_i18n_profile'] ) ) {
$GLOBALS['wpkite_i18n_profile'] = array();
}
$GLOBALS['wpkite_i18n_profile'][ $domain ] = array(
'file' => $mofile,
'start_time' => microtime( true ),
'start_mem' => memory_get_usage(),
);
}, 10, 2 );
add_action( 'loaded_textdomain', function( $domain ) {
if ( isset( $GLOBALS['wpkite_i18n_profile'][ $domain ] ) ) {
$entry = &$GLOBALS['wpkite_i18n_profile'][ $domain ];
$entry['load_time'] = microtime( true ) - $entry['start_time'];
$entry['mem_usage'] = memory_get_usage() - $entry['start_mem'];
}
}, 10, 1 );
// Output profiling data in footer (admin only)
add_action( 'wp_footer', function() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
echo '';
} );
Just-in-Time Translation Loading
WordPress 4.6 introduced just-in-time (JIT) translation loading. Instead of loading all text domains on init, WordPress defers loading until the first call to __() or _e() for that domain. This means plugins whose code never executes on a particular request (because the relevant hook never fires) never have their translations loaded at all.
JIT loading works automatically for plugins and themes that follow WordPress conventions. It requires two conditions: the plugin must call load_plugin_textdomain() during the init action (or rely on the automatic loading for WordPress.org-hosted plugins), and the MO file must be in a location WordPress can predict.
For plugins distributed through WordPress.org, translation files live in WP_LANG_DIR/plugins/ and are loaded automatically. Self-hosted plugins should register their language path early:
function my_plugin_load_textdomain() {
load_plugin_textdomain(
'my-plugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
add_action( 'init', 'my_plugin_load_textdomain' );
The .l10n.php Performance Advantage
Switching from MO files to .l10n.php files is the single biggest performance improvement available for i18n. MO file loading involves opening a binary file, reading and parsing headers, building an in-memory hash table, and then performing lookups against that table. Every request repeats this process unless you use an object cache to store the parsed translations.
The .l10n.php format leverages PHP's OPcache. After the first request compiles the PHP file, the resulting opcode sits in shared memory. Subsequent requests load the translation array directly from OPcache without any file I/O or parsing. The difference on a site with 15 text domains:
# Benchmark results (average of 1000 requests, PHP 8.2, OPcache enabled)
#
# MO files only:
# Total i18n load time: 48.2ms
# Peak memory delta: 4.8 MB
#
# .l10n.php files:
# Total i18n load time: 12.6ms
# Peak memory delta: 2.1 MB
#
# Improvement: 74% faster, 56% less memory
Generate .l10n.php files as part of your build pipeline:
- name: Generate PHP translation files
run: |
for pofile in languages/*.po; do
wp i18n make-php "$pofile" languages/
done
Object Cache for Translation Data
For sites that cannot upgrade to WordPress 6.5+ or cannot use .l10n.php files, caching parsed MO file data in the object cache reduces repeated parsing. WordPress does not do this natively, but you can implement it with a filter:
add_filter( 'override_load_textdomain', function( $override, $domain, $mofile ) {
$cache_key = md5( $mofile . filemtime( $mofile ) );
$cached = wp_cache_get( $cache_key, 'translations' );
if ( false !== $cached ) {
global $l10n;
$l10n[ $domain ] = $cached;
return true; // Tell WordPress we handled the loading
}
return false; // Let WordPress load normally
}, 10, 3 );
add_action( 'loaded_textdomain', function( $domain ) {
global $l10n;
if ( isset( $l10n[ $domain ] ) && $l10n[ $domain ] instanceof MO ) {
$mofile = $l10n[ $domain ]->get_filename();
$cache_key = md5( $mofile . filemtime( $mofile ) );
wp_cache_set( $cache_key, $l10n[ $domain ], 'translations', HOUR_IN_SECONDS );
}
} );
This technique only helps if you have a persistent object cache (Redis, Memcached). The default WordPress object cache is per-request and provides no benefit here.
Managing 20+ Languages: Prioritization, QA, and Community Programs
Supporting many languages simultaneously requires a structured approach to prioritization, quality assurance, and translator community management. Without structure, you end up with three languages at 100% completion, five at 60%, and twelve below 30%, with inconsistent quality across all of them.
Language Tier Prioritization
Divide your target languages into tiers based on user data, market opportunity, and translation resource availability:
Tier 1 (must ship at 100%): Languages representing your largest user populations. Check analytics for locale distribution. For most WordPress products, this includes German, French, Spanish, Japanese, and Brazilian Portuguese. These languages ship with every release and block the release if below 98% completion.
Tier 2 (target 90%+): Languages with growing user bases or strategic market importance. Italian, Dutch, Russian, Korean, Traditional Chinese, and Turkish often fall here. These languages ship when ready but do not block releases.
Tier 3 (community-driven): Languages maintained by volunteer translators. Accept contributions, provide glossaries, but do not commit to completion timelines. Ship whatever percentage is available.
Track completion rates in a dashboard that pulls from the GlotPress API:
function wpkite_translation_dashboard() {
$tiers = array(
1 => array( 'de_DE', 'fr_FR', 'es_ES', 'ja', 'pt_BR' ),
2 => array( 'it_IT', 'nl_NL', 'ru_RU', 'ko_KR', 'zh_TW', 'tr_TR' ),
3 => array( 'ar', 'he_IL', 'pl_PL', 'sv_SE', 'da_DK', 'fi', 'nb_NO', 'cs_CZ', 'el', 'th', 'vi', 'id_ID', 'uk', 'ro_RO', 'hu_HU' ),
);
$thresholds = array( 1 => 98, 2 => 90, 3 => 0 );
foreach ( $tiers as $tier => $locales ) {
echo "<h3>Tier {$tier} (threshold: {$thresholds[$tier]}%)</h3>";
echo '<table><tr><th>Locale</th><th>Translated</th><th>Fuzzy</th><th>Untranslated</th><th>Status</th></tr>';
foreach ( $locales as $locale ) {
$stats = wpkite_get_glotpress_stats( $locale );
$status = $stats['percent'] >= $thresholds[ $tier ] ? 'OK' : 'NEEDS WORK';
printf(
'<tr><td>%s</td><td>%d%%</td><td>%d</td><td>%d</td><td>%s</td></tr>',
esc_html( $locale ),
$stats['percent'],
$stats['fuzzy'],
$stats['untranslated'],
$status
);
}
echo '</table>';
}
}
Translation QA Automation
Automated QA catches common translation errors before they reach users. Build checks that run against every approved translation:
function wpkite_qa_check_translation( string $original, string $translation, string $locale ): array {
$errors = array();
// Check 1: Placeholder count mismatch
$orig_placeholders = array();
preg_match_all( '/%(\d+\$)?[sdfg]/', $original, $orig_placeholders );
$trans_placeholders = array();
preg_match_all( '/%(\d+\$)?[sdfg]/', $translation, $trans_placeholders );
if ( count( $orig_placeholders[0] ) !== count( $trans_placeholders[0] ) ) {
$errors[] = 'Placeholder count mismatch: original has '
. count( $orig_placeholders[0] ) . ', translation has '
. count( $trans_placeholders[0] );
}
// Check 2: HTML tag balance
$orig_tags = array();
preg_match_all( '/<[^>]+>/', $original, $orig_tags );
$trans_tags = array();
preg_match_all( '/<[^>]+>/', $translation, $trans_tags );
if ( count( $orig_tags[0] ) !== count( $trans_tags[0] ) ) {
$errors[] = 'HTML tag count mismatch';
}
// Check 3: Leading/trailing whitespace consistency
if ( preg_match( '/^\s/', $original ) !== preg_match( '/^\s/', $translation ) ) {
$errors[] = 'Leading whitespace mismatch';
}
if ( preg_match( '/\s$/', $original ) !== preg_match( '/\s$/', $translation ) ) {
$errors[] = 'Trailing whitespace mismatch';
}
// Check 4: Untranslated (same as original, except for short strings or proper nouns)
if ( $original === $translation && strlen( $original ) > 20 ) {
$errors[] = 'Translation appears identical to original (possibly untranslated)';
}
// Check 5: Suspicious length ratio
$length_ratio = mb_strlen( $translation ) / max( mb_strlen( $original ), 1 );
if ( $length_ratio > 3.0 || $length_ratio < 0.3 ) {
$errors[] = sprintf( 'Suspicious length ratio: %.1f (original: %d chars, translation: %d chars)',
$length_ratio, mb_strlen( $original ), mb_strlen( $translation ) );
}
return $errors;
}
Run these checks as a GlotPress plugin or as a post-import validation step. Flag translations with errors for human review rather than rejecting them outright, because some "errors" are legitimate (German translations are consistently 30% longer than English, which is normal).
Building a Translation Community
Volunteer translator communities form the backbone of WordPress i18n at scale. The WordPress polyglots team translates WordPress core and thousands of plugins through a community-driven model that works because of several structural decisions.
Each locale has a Locale Manager who approves validators and sets translation standards. Locale-specific glossaries define terminology. Regular translation events (like global translation days tied to WordCamps) create concentrated bursts of translation activity.
For your own product's translation community, invest in these elements: a contributor guide that explains how to use GlotPress and your specific glossary; recognition for top contributors (credits page, profile badges, swag); a communication channel (Slack workspace or forum) where translators can ask about context and discuss terminology choices; and regular translation sprints with clear goals ("Let's get Japanese to 100% before the 3.0 release").
Paid translation is sometimes necessary for Tier 1 languages when community coverage is insufficient. Budget $0.08-0.15 per word for professional WordPress-specialized translators. Many agencies use a hybrid model where machine translation generates the first draft, a professional translator reviews and corrects it, and a community validator performs the final approval. This reduces cost per word to approximately $0.04-0.06 while maintaining quality.
Dynamic Content Translation: Custom Fields, WooCommerce Products, and Database Strings
Everything discussed so far covers static strings defined in source code. But WordPress sites also contain dynamic content: post titles, page content, custom field values, WooCommerce product descriptions, taxonomy names, menu labels, and widget settings. These strings live in the database and require a different translation approach.
Approaches to Database Content Translation
Three architectural approaches exist for translating database content, each with distinct tradeoffs:
Separate posts per language (WPML, Polylang): Each translation is a distinct WordPress post linked to the original. A product in English, German, and Japanese creates three separate posts in wp_posts. Relationships are tracked in a custom table. This approach works well with WordPress caching because each post is independent. The downside is content duplication: shared attributes (price, SKU, stock) must be synced across all language versions.
Single post with multilingual meta (custom implementation): The original post stays as-is, and translations are stored in post meta with locale-suffixed keys. This avoids content duplication but requires custom query modifications and breaks compatibility with plugins that expect standard WordPress post structure.
// Storing translations in post meta
update_post_meta( $post_id, '_title_de_DE', 'Produkttitel auf Deutsch' );
update_post_meta( $post_id, '_title_ja', 'Japanese product title' );
update_post_meta( $post_id, '_content_de_DE', 'German product description...' );
// Retrieving with fallback to original
function wpkite_get_translated_field( int $post_id, string $field, string $locale = '' ): string {
if ( empty( $locale ) ) {
$locale = get_locale();
}
// Try locale-specific meta first
$translated = get_post_meta( $post_id, "_{$field}_{$locale}", true );
if ( ! empty( $translated ) ) {
return $translated;
}
// Fall back to base language (e.g., 'de' if 'de_DE' not found)
$base_locale = substr( $locale, 0, 2 );
$translated = get_post_meta( $post_id, "_{$field}_{$base_locale}", true );
if ( ! empty( $translated ) ) {
return $translated;
}
// Fall back to original post field
$post = get_post( $post_id );
return $post->{"post_{$field}"} ?? '';
}
Gettext-based database translation (less common): Register database strings with the translation system using register_string() (WPML) or a custom implementation. The string enters the same GlotPress workflow as source code strings. This works for small numbers of database strings (site tagline, widget titles) but does not scale to thousands of WooCommerce products.
WooCommerce Product Translation at Scale
WooCommerce stores product data across multiple tables: wp_posts for title and description, wp_postmeta for price, SKU, weight, and custom attributes, wp_terms and wp_term_taxonomy for categories and tags, and (in newer versions) wp_wc_product_meta_lookup for indexed product data.
Translating a WooCommerce catalog with 5,000 products into 10 languages using the "separate posts" approach creates 50,000 posts. This has database implications. The wp_posts table grows tenfold, queries slow down, and the admin interface needs filtering by language to remain usable.
Optimize for this scale with these strategies:
Use database indexes on the language relationship table. Both WPML and Polylang add custom tables for language associations. Ensure these tables have proper indexes on the columns used in JOIN conditions. For WPML's wp_icl_translations table:
-- Verify indexes exist
SHOW INDEX FROM wp_icl_translations;
-- Add missing indexes if needed
ALTER TABLE wp_icl_translations ADD INDEX idx_trid_lang (trid, language_code);
ALTER TABLE wp_icl_translations ADD INDEX idx_element_type (element_type, element_id);
Implement batch translation imports for product catalogs. Instead of translating products one by one through the WordPress admin, build an import pipeline that reads a CSV or JSON file of translations and creates the translated posts programmatically:
function wpkite_import_product_translations( string $csv_path, string $target_locale ): int {
$handle = fopen( $csv_path, 'r' );
$headers = fgetcsv( $handle );
$imported = 0;
while ( ( $row = fgetcsv( $handle ) ) !== false ) {
$data = array_combine( $headers, $row );
$original_id = (int) $data['original_product_id'];
// Check if translation already exists
$existing = wpkite_get_translation_id( $original_id, $target_locale );
if ( $existing ) {
// Update existing translation
wp_update_post( array(
'ID' => $existing,
'post_title' => sanitize_text_field( $data['translated_title'] ),
'post_content' => wp_kses_post( $data['translated_description'] ),
'post_excerpt' => sanitize_text_field( $data['translated_short_description'] ),
) );
} else {
// Create new translation post
$original = get_post( $original_id );
$translated_id = wp_insert_post( array(
'post_type' => 'product',
'post_status' => $original->post_status,
'post_title' => sanitize_text_field( $data['translated_title'] ),
'post_content' => wp_kses_post( $data['translated_description'] ),
'post_excerpt' => sanitize_text_field( $data['translated_short_description'] ),
) );
if ( ! is_wp_error( $translated_id ) ) {
// Copy non-translatable meta (price, SKU, stock)
$shared_meta = array( '_price', '_regular_price', '_sale_price', '_sku', '_stock', '_weight' );
foreach ( $shared_meta as $meta_key ) {
$value = get_post_meta( $original_id, $meta_key, true );
if ( $value !== '' ) {
update_post_meta( $translated_id, $meta_key, $value );
}
}
// Link translation to original
wpkite_set_translation_link( $original_id, $translated_id, $target_locale );
}
}
$imported++;
}
fclose( $handle );
return $imported;
}
Translating Custom Fields (ACF, Meta Box, Pods)
Custom field plugins store data in wp_postmeta. Translating these fields depends on your multilingual architecture. With the "separate posts" approach, each translated post has its own meta, so you simply fill in the translated custom field values on the translated post.
With ACF (Advanced Custom Fields), group your fields into "translatable" and "shared" categories. Translatable fields (text, textarea, WYSIWYG) need separate values per language. Shared fields (numbers, dates, relationships, file uploads) should reference the same value across all language versions.
WPML's Advanced Translation Editor handles ACF fields when you register them correctly. For custom implementations, build a field-level translation system:
/**
* Define which custom fields are translatable.
*/
function wpkite_translatable_fields(): array {
return array(
'product_subtitle' => true, // Translate this
'product_features' => true, // Translate this (repeater text)
'product_video_url' => false, // Share across languages
'product_gallery' => false, // Share across languages
'product_tech_specs' => 'partial', // Translate labels, keep values
);
}
/**
* Copy shared (non-translatable) fields from original to translation.
*/
function wpkite_sync_shared_fields( int $original_id, int $translation_id ): void {
$fields = wpkite_translatable_fields();
foreach ( $fields as $field_name => $translatable ) {
if ( false === $translatable ) {
$value = get_field( $field_name, $original_id );
update_field( $field_name, $value, $translation_id );
}
}
}
URL and Slug Translation
Translating URL slugs improves SEO for each target locale. Instead of /de/product/blue-widget/, users and search engines see /de/produkt/blaues-widget/. Both WPML and Polylang support slug translation, but implementing it custom requires careful rewrite rule management:
// Translate the 'product' post type slug per locale
function wpkite_translated_post_type_slugs( array $args, string $post_type ): array {
if ( 'product' !== $post_type ) {
return $args;
}
$locale = get_locale();
$slug_translations = array(
'de_DE' => 'produkt',
'fr_FR' => 'produit',
'es_ES' => 'producto',
'ja' => 'product', // Keep English for Japanese (common practice)
);
if ( isset( $slug_translations[ $locale ] ) ) {
$args['rewrite']['slug'] = $slug_translations[ $locale ];
}
return $args;
}
add_filter( 'register_post_type_args', 'wpkite_translated_post_type_slugs', 10, 2 );
Remember to flush rewrite rules when the locale changes. Without flushing, the new slugs will not match any rewrite rules and will return 404 errors.
Advanced Patterns and Edge Cases
Multisite Language Architecture
WordPress Multisite offers an alternative architecture for multilingual sites where each language gets its own subsite: example.com (English), example.com/de/ (German), example.com/ja/ (Japanese). The MultilingualPress plugin connects content across subsites without the overhead of WPML's single-site approach.
This architecture has performance advantages. Each subsite has its own set of database tables, so queries for German content never scan English posts. The WordPress object cache works per-site, keeping cache sizes manageable. Admin users see only the content for their language, simplifying the editorial workflow.
The tradeoff is content synchronization complexity. Creating a new product requires creating it on every subsite (or building automation to propagate it). Shared media must use a centralized media library. User management spans the network, adding administrative overhead.
Locale Fallback Chains
Not every locale has complete translations for every plugin. WordPress 6.4+ supports locale fallback chains, where a locale can specify parent locales to check when a translation is missing. For example, de_CH (Swiss German) can fall back to de_DE (Standard German) and then to English.
Configure fallback chains in your plugin for locales that share a base language:
add_filter( 'load_textdomain_mofile', function( string $mofile, string $domain ): string {
if ( 'my-plugin' !== $domain ) {
return $mofile;
}
// If the requested MO file does not exist, try fallback locales
if ( ! file_exists( $mofile ) ) {
$locale = get_locale();
$fallbacks = array(
'de_CH' => array( 'de_DE_formal', 'de_DE' ),
'de_AT' => array( 'de_DE_formal', 'de_DE' ),
'fr_BE' => array( 'fr_FR' ),
'fr_CA' => array( 'fr_FR' ),
'es_MX' => array( 'es_ES' ),
'es_AR' => array( 'es_ES' ),
'pt_PT' => array( 'pt_BR' ),
'en_GB' => array( 'en_US' ),
'en_AU' => array( 'en_GB', 'en_US' ),
);
if ( isset( $fallbacks[ $locale ] ) ) {
$base_path = dirname( $mofile );
foreach ( $fallbacks[ $locale ] as $fallback_locale ) {
$fallback_file = "{$base_path}/{$domain}-{$fallback_locale}.mo";
if ( file_exists( $fallback_file ) ) {
return $fallback_file;
}
}
}
}
return $mofile;
}, 10, 2 );
Date, Number, and Currency Formatting
Translation is more than text. Dates, numbers, and currencies must follow locale conventions. March 14, 2024, displays as 14.03.2024 in Germany, 2024/03/14 in Japan, and 14/03/2024 in France. WordPress handles date formatting through date_i18n() and the locale's date format setting, but custom code often bypasses this.
Use the wp_date() function (WordPress 5.3+) for locale-aware date formatting:
// WRONG: not locale-aware
echo date( 'F j, Y', $timestamp );
// RIGHT: respects locale date format and translations
echo wp_date( get_option( 'date_format' ), $timestamp );
For number formatting, PHP's number_format_i18n() handles thousand separators and decimal points according to the locale. For currency formatting in WooCommerce, the wc_price() function handles locale-specific currency symbol placement, decimal separators, and thousand separators.
Handling Strings with HTML Markup
Strings containing HTML markup create translation challenges. Translators need to preserve the HTML structure while translating the text content. The WordPress approach is to use placeholders for complex HTML and allow simple tags in the translatable string:
// OK: simple formatting tags that translators can handle
printf(
/* translators: %s is the plugin name wrapped in a bold tag */
__( 'Thank you for installing <strong>%s</strong>. Visit the settings page to get started.', 'my-plugin' ),
esc_html( $plugin_name )
);
// BETTER for complex HTML: use multiple placeholders
printf(
/* translators: 1: opening link tag, 2: closing link tag */
__( 'Read the %1$sdocumentation%2$s for setup instructions.', 'my-plugin' ),
'<a href="' . esc_url( $docs_url ) . '">',
'</a>'
);
The second approach keeps HTML out of the translatable string entirely, reducing the chance that a translator will accidentally break the markup. It also means that if you change the URL or add a CSS class to the link, you do not need a new translation.
Multiline and Long String Management
PO files handle long strings by splitting them across multiple lines. The first msgid line is empty, and subsequent lines are concatenated:
msgid ""
"This is a very long string that spans multiple lines in the PO file. "
"The PO format concatenates all these lines into a single string at "
"runtime, which keeps the file readable."
msgstr ""
"Dies ist ein sehr langer String, der sich in der PO-Datei über mehrere "
"Zeilen erstreckt. Das PO-Format verkettet alle diese Zeilen zur "
"Laufzeit zu einem einzigen String."
Keep translatable strings to a reasonable length. Paragraphs of text should not be single translatable strings. Break them into logical sentences or sections. This makes translation easier, reduces the impact of string changes (only the changed sentence needs re-translation), and improves translation memory hit rates.
Putting It All Together: A Production Pipeline
Here is how these components connect into a production-grade localization pipeline for a WordPress plugin supporting 20+ languages:
Development phase: Developers write code using WordPress i18n functions. Translator comments accompany every string with placeholders. The text domain matches the plugin slug. JSX blocks use @wordpress/i18n functions. A pre-commit hook runs wp i18n make-pot to catch extraction issues early.
CI extraction: Every push to the main branch triggers the CI pipeline. It extracts strings, compares against the previous POT file, uploads changes to GlotPress, and notifies translators of new or changed strings via Slack or email.
Machine pre-translation: A scheduled job runs every 6 hours, pulling untranslated strings from GlotPress for Tier 1 and Tier 2 languages, sending them through DeepL (for European languages) or Google Translate (for Asian and other languages), and importing the results as fuzzy suggestions in GlotPress.
Human review: Validators for each locale review machine-translated suggestions, checking them against the glossary and translation memory. Approved translations enter the "current" state. The QA automation validates placeholder consistency, HTML structure, and length ratios.
Build and deploy: The release pipeline exports approved translations from GlotPress as PO files, compiles them to MO files with msgfmt, generates .l10n.php files with wp i18n make-php, creates JSON translation files for JavaScript blocks with wp i18n make-json, generates RTL stylesheets with RTLCSS, runs the QA check suite, and packages everything for distribution.
Runtime: WordPress loads .l10n.php files from OPcache for PHP translations, loads JSON files for block editor translations, applies RTL stylesheets automatically for RTL locales, and uses JIT loading to avoid loading text domains for unused plugins.
Monitoring: A dashboard tracks translation completion percentages per locale and tier, flags translations that have been in "fuzzy" state for more than 7 days, reports on i18n-related PHP warnings from error logs, and measures translation loading time as part of performance monitoring.
This pipeline handles the full lifecycle from string authoring to production delivery, scaling smoothly from 3 languages to 30. The initial setup requires a week of engineering effort. After that, it runs on autopilot with occasional glossary updates and validator onboarding.
The most common failure mode in large-scale WordPress i18n is not technical but organizational: translations stall because no one monitors completion rates, machine translations go unreviewed for weeks, and glossaries become outdated. The automation described here reduces the human effort required, but it does not eliminate it. Someone must own the translation process, hold validators accountable for review turnaround, and advocate for i18n quality in release planning discussions. The tooling makes the work manageable. The organizational commitment makes it happen.
Nadia Okafor
Full-stack WordPress developer with a focus on internationalization, transactional email, and background processing. Works with clients across 15 countries.