WordPress Internationalization (i18n) & Localization (l10n)
This skill covers making WordPress plugins and themes translatable, generating translation files, and handling JavaScript translations.
Core Concepts
| Term | Meaning |
|---|---|
| i18n | Internationalization — preparing code for translation |
| l10n | Localization — translating strings for a specific locale |
| Text domain | Unique identifier matching your plugin/theme slug |
| POT file | Portable Object Template — source strings catalog |
| PO file | Portable Object — translated strings for one locale |
| MO file | Machine Object — compiled PO for runtime use |
| JSON | JavaScript translation files (WP 5.0+) |
PHP Translation Functions
Basic Functions
// Return translated string (most common)
$label = __( 'Settings', 'myplugin' );
// Echo translated string
_e( 'Save Changes', 'myplugin' );
// Singular/plural
$message = sprintf(
/* translators: %d: number of items */
_n( '%d item found', '%d items found', $count, 'myplugin' ),
$count
);
// Context-disambiguated (same English string, different meaning)
$label = _x( 'Post', 'noun — a blog post', 'myplugin' );
_ex( 'Post', 'verb — to publish', 'myplugin' );
// Singular/plural with context
$label = _nx( '%d item', '%d items', $count, 'cart items', 'myplugin' );
Escaping + Translation Combos
Always use these when outputting translated strings in HTML:
// Escape for HTML body
echo esc_html__( 'Untrusted translated text', 'myplugin' );
esc_html_e( 'Untrusted translated text', 'myplugin' );
// Escape for HTML attributes
echo esc_attr__( 'Button label', 'myplugin' );
esc_attr_e( 'Button label', 'myplugin' );
// Rich HTML — use wp_kses on translated strings containing markup
printf(
wp_kses(
/* translators: %s: settings page URL */
__( 'Configure <a href="%s">settings</a>.', 'myplugin' ),
[ 'a' => [ 'href' => [] ] ]
),
esc_url( $settings_url )
);
Function Reference
| Function | Returns | Escapes | Context | Plural |
|---|---|---|---|---|
__() |
string | No | No | No |
_e() |
void (echoes) | No | No | No |
_n() |
string | No | No | Yes |
_x() |
string | No | Yes | No |
_ex() |
void (echoes) | No | Yes | No |
_nx() |
string | No | Yes | Yes |
esc_html__() |
string | HTML | No | No |
esc_html_e() |
void (echoes) | HTML | No | No |
esc_attr__() |
string | Attr | No | No |
esc_attr_e() |
void (echoes) | Attr | No | No |
esc_html_x() |
string | HTML | Yes | No |
esc_attr_x() |
string | Attr | Yes | No |
Translator Comments
Required before any string with placeholders. The comment must be on the line immediately before the function call:
/* translators: %s: user display name */
$greeting = sprintf( __( 'Hello, %s!', 'myplugin' ), $user->display_name );
/* translators: 1: start date, 2: end date */
$range = sprintf(
__( 'From %1$s to %2$s', 'myplugin' ),
$start_date,
$end_date
);
/* translators: %d: number of results */
$summary = sprintf(
_n( '%d result', '%d results', $count, 'myplugin' ),
number_format_i18n( $count )
);
Rules:
- Use
/* translators: ... */format (block comment, not//) - Describe each placeholder:
%s,%d,%1$s, etc. - Keep translator comments short and descriptive
- Never concatenate translatable strings — translators need full sentence context
Loading the Text Domain
Plugins
add_action( 'init', 'myplugin_load_textdomain' );
function myplugin_load_textdomain(): void {
load_plugin_textdomain(
'myplugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
As of WordPress 6.7+, plugins hosted on WordPress.org get translations loaded automatically — load_plugin_textdomain() is still needed for custom/private plugins.
Themes
add_action( 'after_setup_theme', 'mytheme_load_textdomain' );
function mytheme_load_textdomain(): void {
load_theme_textdomain( 'mytheme', get_template_directory() . '/languages' );
}
Generating Translation Files
Using WP-CLI (Recommended)
# Generate POT file from PHP source
wp i18n make-pot . languages/myplugin.pot --slug=myplugin --domain=myplugin
# Generate JSON files for JavaScript translations
wp i18n make-json languages/ --no-purge
# Generate MO from PO
wp i18n make-mo languages/
POT File Placement
my-plugin/
├── languages/
│ ├── myplugin.pot # Source template
│ ├── myplugin-fr_FR.po # French translations
│ ├── myplugin-fr_FR.mo # Compiled French
│ ├── myplugin-de_DE.po # German translations
│ ├── myplugin-de_DE.mo # Compiled German
│ ├── myplugin-fr_FR-{md5}.json # French JS translations
│ └── myplugin-de_DE-{md5}.json # German JS translations
JavaScript Translations (WP 5.0+)
Registering JS Translations
add_action( 'wp_enqueue_scripts', 'myplugin_enqueue_scripts' );
function myplugin_enqueue_scripts(): void {
wp_enqueue_script(
'myplugin-frontend',
plugin_dir_url( __FILE__ ) . 'build/frontend.js',
[],
MYPLUGIN_VERSION,
true
);
wp_set_script_translations(
'myplugin-frontend', // Must match the script handle
'myplugin', // Text domain
plugin_dir_path( __FILE__ ) . 'languages'
);
}
Using Translations in JavaScript
import { __, _n, _x, sprintf } from '@wordpress/i18n';
// Basic translation
const label = __( 'Settings', 'myplugin' );
// Plural
const message = sprintf(
_n( '%d item', '%d items', count, 'myplugin' ),
count
);
// With context
const action = _x( 'Post', 'verb', 'myplugin' );
// With placeholder
const greeting = sprintf(
/* translators: %s: user name */
__( 'Hello, %s!', 'myplugin' ),
userName
);
Generating JSON Translation Files
After creating PO translations, generate the JSON files WP needs for JS:
# From the plugin root
wp i18n make-json languages/ --no-purge
The --no-purge flag keeps the original strings in the PO/MO files (needed if the same string is used in both PHP and JS).
Common i18n Mistakes
Never Do This
// WRONG: Concatenated strings — translators can't reorder words
echo __( 'Posted on ', 'myplugin' ) . $date . __( ' by ', 'myplugin' ) . $author;
// CORRECT: Full sentence with placeholders
printf(
/* translators: 1: date, 2: author name */
esc_html__( 'Posted on %1$s by %2$s', 'myplugin' ),
esc_html( $date ),
esc_html( $author )
);
// WRONG: Variable as text domain
__( 'Hello', $domain );
// CORRECT: Text domain must be a string literal
__( 'Hello', 'myplugin' );
// WRONG: Translating HTML tags
__( '<strong>Important:</strong> Save your work.', 'myplugin' );
// CORRECT: Keep HTML outside or use wp_kses
sprintf(
'<strong>%s</strong> %s',
esc_html__( 'Important:', 'myplugin' ),
esc_html__( 'Save your work.', 'myplugin' )
);
// WRONG: Dynamic strings
__( "Hello $name", 'myplugin' );
// CORRECT: sprintf with placeholder
sprintf( __( 'Hello %s', 'myplugin' ), $name );
// WRONG: Escaping before translation
__( esc_html( $string ), 'myplugin' );
// CORRECT: Escape after translation
esc_html__( 'Static string', 'myplugin' );
// Or for dynamic: esc_html( __( 'String', 'myplugin' ) );
Number and Date Formatting
// Use WordPress locale-aware formatting
$formatted_number = number_format_i18n( 1234567.89, 2 ); // "1,234,567.89" in en_US
$formatted_date = wp_date( 'F j, Y', $timestamp ); // Locale-aware date
$formatted_date = date_i18n( 'F j, Y', $timestamp ); // Legacy (still works)
// Never use PHP's number_format() or date() directly — they ignore locale
RTL (Right-to-Left) Support
// In functions.php — WP handles this automatically if you enqueue properly
function mytheme_enqueue_styles(): void {
wp_enqueue_style( 'mytheme-style', get_stylesheet_uri() );
// WP auto-loads style-rtl.css when locale is RTL
wp_style_add_data( 'mytheme-style', 'rtl', 'replace' );
}
// In CSS — use logical properties
.widget {
margin-inline-start: 1rem; /* left in LTR, right in RTL */
padding-inline-end: 1rem; /* right in LTR, left in RTL */
}
// Check direction in PHP
if ( is_rtl() ) {
// RTL-specific logic
}
i18n Checklist
- Every user-facing string wrapped in
__(),_e(),_n(),_x(), or escaped variants - Text domain matches plugin/theme slug exactly (string literal, never variable)
-
/* translators: */comment before every string with placeholders - No string concatenation for translatable content — use
sprintf()with numbered placeholders -
load_plugin_textdomain()orload_theme_textdomain()called - POT file generated with
wp i18n make-pot - JS translations registered with
wp_set_script_translations() - JSON files generated with
wp i18n make-json(if JS strings exist) - Numbers formatted with
number_format_i18n() - Dates formatted with
wp_date()ordate_i18n() - RTL stylesheet provided (
style-rtl.cssor logical CSS properties) - No HTML inside translation strings (or properly handled with
wp_kses)