WordPress Localization and Internationalization โ Translating Themes and Plugins
In this tutorial, you'll learn how to make WordPress themes and plugins translatable using i18n functions like __(), _e(), _n(), and _x(), create .pot files, translate with Poedit, and load text domains properly.
What You'll Learn
- The difference between i18n (internationalization) and l10n (localization)
- What a text domain is and how it namespaces translations
- All i18n functions: __(), e(), x(), n(), nx(), esc_html(), esc_html_e(), esc_attr(), esc_attr_e()
- How to load text domains in themes and plugins
- How to create .pot, .po, and .mo files with Poedit and WP-CLI
- How to include variables in translated strings with printf and sprintf
- How to format numbers and dates for different locales
- How to localize JavaScript with wp_localize_script()
- How to add right-to-left (RTL) language support with rtl.css
Why It Matters
WordPress powers over 40% of the web, and a large portion of those sites are not in English. If your theme or plugin is only available in one language, you exclude millions of potential users. Internationalization โ preparing your code for translation โ makes your work accessible globally. The WordPress community has translated the core software into over 200 languages. Your theme or plugin can be part of that ecosystem. Even if you never translate it yourself, making your code translatable means users can contribute translations in their own language.
Real-World Use
A German web developer installs your plugin but all buttons and messages appear in English. The plugin works, but the German user has to guess what each setting does. If you had wrapped every string in __() and provided a .pot file, the community could translate it to German within hours. The same plugin now works for French, Spanish, Japanese, and Arabic users โ without you writing a single translation. The work is done entirely by the community.
Learning Path
flowchart LR
A[REST API] --> B[Localization]
B --> C[WooCommerce Setup]
B --> D[Security Hardening]
B --> E[Performance Optimization]
style B fill:#4a90d9,color:#fff
What Is i18n and l10n?
These abbreviations are shorthand for long words:
- i18n = internationalization (18 letters between i and n). The Process of writing code so it can handle multiple languages without rewriting.
- l10n = localization (10 letters between l and n). The process of translating the interface text into a specific language.
Think of it this way: internationalization is preparing the kitchen to cook any cuisine. Localization is cooking a specific dish. You do i18n once in your code. You can do l10n many times for many languages.
How It Works in WordPress
WordPress stores translations in .mo (Machine Object) files โ compiled binary files that PHP reads quickly. Each language has its own .mo file. When a user sets their site language to French, WordPress loads the French .mo file, and every translated string automatically appears in French.
English: "Hello World" (source, in code)
French: "Bonjour le monde" (in fr_FR.mo)
German: "Hallo Welt" (in de_DE.mo)
Arabic: "ู
ุฑุญุจุง ุจุงูุนุงูู
" (in ar_AR.mo)
Text Domain
A text domain is a unique identifier that tells WordPress which set of translations belongs to which theme or plugin. It acts like a namespace.
Rules for Text Domains
- The text domain must match the theme/plugin slug
- The text domain must match the
Text Domain:header in style.css or the plugin header - The text domain must match the folder name used in
load_theme_textdomain()orload_plugin_textdomain() - Use hyphens, not underscores (e.g.,
my-custom-theme, notmy_custom_theme)
In a Theme (style.css)
/*
Theme Name: My Custom Theme
Text Domain: my-custom-theme
*/
In a Plugin (plugin header)
<?php
/**
* Plugin Name: My Custom Plugin
* Text Domain: my-custom-plugin
*/
i18n Functions
WordPress provides several PHP functions for internationalization. Each has a specific purpose.
__() โ Return Translated String
The most common i18n function. It returns the translated version of a string:
<?php
// Basic usage
$translated = __('Hello World', 'my-text-domain');
// In a template
echo __('Welcome to our website', 'my-text-domain');
// Assign to variable
$button_text = __('Submit Form', 'my-text-domain');
?>
The first parameter is the text to translate (the source string). The second is the text domain.
_e() โ Echo Translated String
Identical to __() but echoes the result directly instead of returning it:
<?php
// Echo directly โ no echo() call needed
_e('Read More', 'my-text-domain');
// These two lines do the same thing:
echo __('Read More', 'my-text-domain');
_e('Read More', 'my-text-domain');
?>
Use _e() when you only need to output the string and do not need to use it elsewhere.
_x() โ Translation with Context
Some words have different meanings depending on context. The _x() function adds a context string to help translators:
<?php
// The word "Post" could be a noun (a blog post) or a verb (to post something)
// _x() adds context to disambiguate
// As a noun:
echo _x('Post', 'noun - a blog post', 'my-text-domain');
// As a verb:
echo _x('Post', 'verb - to publish', 'my-text-domain');
?>
Without context, a translator sees "Post" once and does not know which meaning to use. With context, they can translate each usage correctly.
_n() โ Singular and Plural
Strings that change form depending on a number need special handling:
<?php
// Parameters: single, plural, number, text domain
$comments_count = 5;
echo _n('1 comment', '%s comments', $comments_count, 'my-text-domain');
// Output: "5 comments"
?>
The %s placeholder is replaced by the number. WordPress handles grammar rules for languages that have multiple plural forms (like Russian and Arabic which have different forms for 1, 2-4, and 5+).
_nx() โ Singular/Plural with Context
Combines _n() and _x() for translated plurals with context:
<?php
$found = 3;
echo _nx('%s result found', '%s results found', $found, 'search results', 'my-text-domain');
// Output: "3 results found"
?>
esc_html__() โ Translated + Escaped for HTML
Returns a translated string that has been escaped for safe HTML output:
<?php
// Safe for use inside HTML tags
echo esc_html__('User <script>alert("xss")</script>', 'my-text-domain');
// Output: "User <script>alert("xss")</script>"
?>
esc_html_e() โ Echo + Escape for HTML
Echoes a translated and HTML-escaped string:
<?php
esc_html_e('This text is safe for HTML output', 'my-text-domain');
?>
esc_attr__() โ Translated + Escaped for Attributes
Returns a translated string escaped for use in HTML attributes:
<?php
// Safe inside an HTML attribute value
echo '<input type="text" placeholder="' . esc_attr__('Enter your name', 'my-text-domain') . '">';
?>
esc_attr_e() โ Echo + Escape for Attributes
Echoes a translated string escaped for attributes:
<?php
echo '<input type="submit" value="';
esc_attr_e('Save Changes', 'my-text-domain');
echo '">';
?>
Function Reference Table
| Function | Returns | Escapes | Use Case |
|---|---|---|---|
__() |
String | No | Store in variable, pass to functions |
_e() |
Echoes | No | Output directly in templates |
_x() |
String | No | Disambiguate with context |
_n() |
String | No | Singular/plural based on count |
_nx() |
String | No | Singular/plural with context |
esc_html__() |
String | HTML | Safe inside HTML body |
esc_html_e() |
Echoes | HTML | Output safely in HTML |
esc_attr__() |
String | Attribute | Safe in HTML attributes |
esc_attr_e() |
Echoes | Attribute | Output safely in attributes |
Load Text Domain in Themes
You must tell WordPress where to find your translation files. In themes, use load_theme_textdomain() in functions.php:
<?php
function my_theme_setup() {
// Load translations from /wp-content/themes/my-theme/languages/
load_theme_textdomain('my-text-domain', get_template_directory() . '/languages');
}
add_action('after_setup_theme', 'my_theme_setup');
?>
Expected File Structure
wp-content/themes/my-theme/
languages/
fr_FR.mo # French translation (compiled)
fr_FR.po # French translation (editable)
de_DE.mo # German translation
de_DE.po
my-theme.pot # Source template (generated from code)
style.css
functions.php
index.php
Language File Naming Convention
{locale}.mo โ fr_FR.mo
{locale}.po โ fr_FR.po
{domain}-{locale}.mo โ my-text-domain-fr_FR.mo (alternative)
The locale format is language_CODE where language is the ISO 639-1 language code and CODE is the ISO 3166-1 country code:
fr_FR= French (France)de_DE= German (Germany)es_ES= Spanish (Spain)ar_AR= Arabic (world)pt_BR= Portuguese (Brazil)en_GB= English (United Kingdom)
Load Text Domain in Plugins
In plugins, use load_plugin_textdomain():
<?php
function my_plugin_load_textdomain() {
load_plugin_textdomain(
'my-text-domain',
false,
dirname(plugin_basename(__FILE__)) . '/languages/'
);
}
add_action('plugins_loaded', 'my_plugin_load_textdomain');
?>
Expected Plugin File Structure
wp-content/plugins/my-plugin/
languages/
fr_FR.mo
fr_FR.po
my-plugin.pot
my-plugin.php
Creating .pot File
The .pot (Portable Object Template) file is the master list of all translatable strings in your code. It is generated from scanning your source files. You never edit it directly โ it is the template from which translators create .po files.
Method 1: Using WP-CLI
WP-CLI is the command-line tool for WordPress management. It can generate .pot files:
# Generate a .pot file for a theme
wp i18n make-pot wp-content/themes/my-theme/ wp-content/themes/my-theme/languages/my-theme.pot
# Generate for a plugin
wp i18n make-pot wp-content/plugins/my-plugin/ wp-content/plugins/my-plugin/languages/my-plugin.pot
# With custom domain and package name
wp i18n make-pot wp-content/themes/my-theme/ languages/my-theme.pot \
--slug="my-theme" \
--domain="my-text-domain" \
--package-name="My Theme"
Method 2: Using Poedit
Poedit (https://poedit.net) is a desktop application for translation. It can also scan source code:
- Open Poedit
- Click File > New
- Select the language you are translating from (always the source language)
- Save the empty .po file to your languages folder
- Click Catalog > Properties
- Set Source paths to your theme/plugin directory
- Set Source keywords to include all i18n functions:
__;_e;_x;_n;_nx;esc_html__;esc_html_e;esc_attr__;esc_attr_e - Click OK
- Click Catalog > Update from sources
- Poedit scans your code and populates the translation list
- Save โ Poedit creates both
.poand.mofiles
Method 3: Loco Translate Plugin
Loco Translate is a plugin that lets you manage translations from the WordPress admin:
- Install Loco Translate from the WordPress plugin Repository
- Go to Loco Translate > Themes or Plugins
- Select your theme or plugin
- Click New language
- Choose the language and location (system or custom)
- Translate strings in the browser
- Save โ Loco creates
.poand.mofiles automatically
Translating with Poedit
Once you have a .po file with the extracted strings, here is how the translation process works:
The Translation Interface
In Poedit, you see three columns:
Source String | Translation | Status
---------------------------------|--------------------------------|--------
Read More | Lire la suite | Translated
Search results for %s | Resultats de recherche pour %s | Translated
1 comment | 1 commentaire | Needs review
Each row shows:
- Source String โ the original English text from your code
- Translation โ the translated text
- Status โ whether it is translated, needs review, or is untranslated
Saving Files
When you save in Poedit:
- The
.pofile is updated with your translations (human-readable) - The
.mofile is generated (machine-readable, used by PHP)
Always upload both files. WordPress reads the .mo file. The .po file is only for editing.
Translating Plural Forms
Some languages have complex plural rules. Poedit handles this automatically:
English: 1 comment / 2 comments / 5 comments (2 forms)
French: 1 commentaire / 2 commentaires (2 forms)
Russian: 1 ะบะพะผะผะตะฝัะฐัะธะน / 2 ะบะพะผะผะตะฝัะฐัะธั / 5 ะบะพะผะผะตะฝัะฐัะธะตะฒ (3 forms)
Arabic: 1 ุชุนููู / 2 ุชุนูููุงู / 5 ุชุนูููุงุช / 11 ุชุนูููุงู (6 forms)
WordPress uses _n() to handle plurals. The .po file provides translations for each plural form.
Translating Theme Strings in Templates
Here is how a real theme template looks with i18n functions:
<!-- In a theme template file (e.g., index.php or archive.php) -->
<article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<p class="post-meta">
<?php
printf(
/* translators: 1: post date, 2: post author */
esc_html__('Posted on %1$s by %2$s', 'my-text-domain'),
get_the_date(),
get_the_author()
);
?>
</p>
<div class="entry-content">
<?php the_excerpt(); ?>
<a href="<?php the_permalink(); ?>"><?php esc_html_e('Continue reading', 'my-text-domain'); ?></a>
</div>
<footer class="entry-footer">
<?php
if (has_tag()) {
echo '<span class="tags">';
esc_html_e('Tagged: ', 'my-text-domain');
the_tags('', ', ');
echo '</span>';
}
?>
</footer>
</article>
PHP Template Example with All Functions
<?php
// Using __() to return a translated string
$site_title = __('My WordPress Site', 'my-text-domain');
// Using _e() to echo directly
_e('Welcome to our site', 'my-text-domain');
// Using _x() with context
echo _x('Post', 'noun - a blog post', 'my-text-domain');
// Using _n() for plurals
$count = 5;
echo _n('1 item', '%s items', $count, 'my-text-domain');
// Using _nx() for plurals with context
echo _nx('1 result found', '%s results found', $count, 'search results', 'my-text-domain');
// Escaped versions for safe output
echo esc_html__('Safe HTML title', 'my-text-domain');
esc_html_e('Safe HTML content', 'my-text-domain');
// For HTML attributes
echo '<a href="#" title="' . esc_attr__('Click here', 'my-text-domain') . '">';
esc_attr_e('Read More', 'my-text-domain');
echo '</a>';
?>
Including Variables in Translations
You cannot include dynamic values directly in translatable strings because translators would have to duplicate each variant:
// โ WRONG โ translator would need to translate every possible name
echo __('Hello John', 'my-text-domain');
echo __('Hello Sarah', 'my-text-domain');
echo __('Hello Mike', 'my-text-domain');
// โ
CORRECT โ use %s placeholder and printf/sprintf
printf(
__('Hello %s', 'my-text-domain'),
$user_name
);
Using printf() for Variable Substitution
<?php
$username = 'John';
$site_name = 'My Site';
printf(
/* translators: 1: username, 2: site name */
esc_html__('Welcome back, %1$s! You are logged into %2$s.', 'my-text-domain'),
$username,
$site_name
);
// Output: "Welcome back, John! You are logged into My Site."
?>
The %1$s, %2$s syntax allows reordering. Translators can rearrange placeholder positions if the grammar of their language requires it:
<?php
// English: "Posts by John in Category"
printf(
__('Posts by %1$s in %2$s', 'my-text-domain'),
$author_name,
$category_name
);
// If a language needs: "Posts in Category by John"
// The translator can use: "Posts in %2$s by %1$s"
?>
Translators Comment
Always add a translators comment before complex strings using /* translators: */. This comment appears in the .po file to help translators understand the context:
<?php
/* translators: %s: number of new messages */
echo _n('You have %s new message.', 'You have %s new messages.', $count, 'my-text-domain');
/* translators: 1: post title, 2: category name */
printf(
__('The post "%1$s" is filed under %2$s.', 'my-text-domain'),
$post_title,
$category_name
);
?>
In Poedit, the translator sees:
Source: You have %s new message.
Comment: translators: %s: number of new messages
Number Formatting
Different locales format numbers differently. In the US, 1,234.56 is common. In Germany, it is 1.234,56. Use number_format_i18n() to handle this:
<?php
$price = 1234.56;
// English (US): 1,234.56
echo number_format_i18n($price, 2);
// German (DE): 1.234,56
echo number_format_i18n($price, 2); // Automatically uses the locale's format
?>
With Currency Formatting
<?php
$price = 49.99;
/* translators: %s: price with currency symbol */
printf(
__('Price: %s', 'my-text-domain'),
'$' . number_format_i18n($price, 2)
);
?>
Date/Time Formatting
Use date_i18n() to display dates in the correct format for the current locale. It translates month and day names automatically:
<?php
$timestamp = time(); // Current timestamp
// "June 27, 2026" (English) or "27 juin 2026" (French)
echo date_i18n('F j, Y', $timestamp);
// Use WordPress date format setting
echo date_i18n(get_option('date_format'), $timestamp);
// With time
echo date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $timestamp);
?>
The Time Since Function
<?php
// human_time_diff() shows relative time like "2 hours ago"
// It is already localized via WordPress core translations
printf(
/* translators: %s: human-readable time difference */
esc_html__('%s ago', 'my-text-domain'),
human_time_diff(get_the_time('U'), current_time('timestamp'))
);
// Output: "3 hours ago" or "il y a 3 heures" (French)
?>
Localizing Scripts
When you enqueue JavaScript that contains strings, you need to make those strings available to JavaScript. Use wp_localize_script():
In PHP (functions.php or plugin):
<?php
function my_theme_scripts() {
// Enqueue the script first
wp_enqueue_script(
'my-theme-script',
get_template_directory_uri() . '/js/script.js',
array(),
'1.0.0',
true
);
// Localize strings for JavaScript
wp_localize_script('my-theme-script', 'myThemeI18n', array(
'confirmDelete' => __('Are you sure you want to delete this?', 'my-text-domain'),
'loading' => __('Loading...', 'my-text-domain'),
'error' => __('Something went wrong.', 'my-text-domain'),
'submit' => __('Submit', 'my-text-domain'),
));
}
add_action('wp_enqueue_scripts', 'my_theme_scripts');
?>
In JavaScript (script.js):
// Use the localized strings
document.getElementById('delete-button').addEventListener('click', function(e) {
if (!confirm(myThemeI18n.confirmDelete)) {
e.preventDefault();
}
});
document.getElementById('submit-form').addEventListener('submit', function() {
const button = this.querySelector('button');
button.textContent = myThemeI18n.loading;
button.disabled = true;
});
Real-World Example: AJAX with Localized Strings
// PHP: Enqueue and localize
function my_theme_load_more_scripts() {
wp_enqueue_script('my-load-more', get_template_directory_uri() . '/js/load-more.js', array('jquery'), '1.0', true);
wp_localize_script('my-load-more', 'myLoadMore', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'loadMore' => __('Load More Posts', 'my-text-domain'),
'loading' => __('Loading...', 'my-text-domain'),
'noMore' => __('No more posts to load.', 'my-text-domain'),
'error' => __('Error loading posts.', 'my-text-domain'),
'nonce' => wp_create_nonce('load_more_nonce'),
));
}
add_action('wp_enqueue_scripts', 'my_theme_load_more_scripts');
// JavaScript: load-more.js
jQuery(document).ready(function($) {
$('#load-more-button').text(myLoadMore.loadMore);
$('#load-more-button').on('click', function() {
const button = $(this);
button.text(myLoadMore.loading);
$.ajax({
url: myLoadMore.ajaxUrl,
type: 'POST',
data: {
action: 'load_more_posts',
nonce: myLoadMore.nonce,
page: button.data('page')
},
success: function(response) {
if (response.success) {
if (response.data.has_more) {
button.text(myLoadMore.loadMore);
button.data('page', response.data.next_page);
} else {
button.text(myLoadMore.noMore);
button.prop('disabled', true);
}
$('#posts-container').append(response.data.html);
} else {
button.text(myLoadMore.error);
}
},
error: function() {
button.text(myLoadMore.error);
}
});
});
});
Right-to-Left Language Support
Languages like Arabic, Hebrew, Urdu, and Persian are written right-to-left (RTL). WordPress supports RTL through rtl.css.
How RTL Works
When a user selects an RTL language (e.g., Arabic), WordPress:
- Adds
rtlclass to the<body>tag - Loads
rtl.cssafter the main stylesheet if it exists - Flips margins, padding, float directions automatically if
rtl.cssis properly written
Creating rtl.css
Create an rtl.css file in your theme root. It contains CSS overrides that flip the layout:
/* In rtl.css โ flip text direction */
body {
direction: rtl;
unicode-bidi: embed;
}
/* Fix alignment */
.alignleft {
float: right;
margin-left: 20px;
margin-right: 0;
}
.alignright {
float: left;
margin-right: 20px;
margin-left: 0;
}
/* Fix text alignment */
.text-left {
text-align: right;
}
.text-right {
text-align: left;
}
/* Fix padding/margin for RTL */
.site-branding {
float: right;
}
.main-navigation {
float: left;
}
/* Fix icon direction */
.dropdown-toggle::after {
margin-right: 5px;
margin-left: 0;
}
Modern Alternative: CSS Logical Properties
Modern CSS supports logical properties that automatically adapt to writing direction:
/* Instead of margin-left/margin-right, use margin-inline-start/margin-inline-end */
.element {
margin-inline-start: 20px; /* margin-left in LTR, margin-right in RTL */
padding-inline-end: 10px; /* padding-right in LTR, padding-left in RTL */
border-inline-start: 2px solid #000;
}
/* Instead of float: left/right, use inset-inline-start/end */
.container {
display: flex;
}
.item {
margin-inline-end: auto;
}
Using logical properties means you may not need rtl.css at all โ the CSS adapts automatically.
Common Mistakes
Not including the text domain: Every i18n function needs a text domain as the second parameter. Leaving it out causes the string to never be translated. Always pass the text domain:
__('Hello', 'my-text-domain')not__('Hello').Using concatenation inside translation functions: You cannot concatenate strings inside
__()because translators see only separate fragments:// WRONG โ translators see "Hello " and " World" separately echo __('Hello ', 'my-text-domain') . __('World', 'my-text-domain'); // CORRECT โ one string with placeholder printf(__('Hello %s', 'my-text-domain'), 'World');Hardcoding strings instead of using i18n functions: Every user-facing string must use an i18n function. Debug logs and internal messages do not need translation, but everything visible to users does.
Incorrect text domain in style.css: If the text domain in the style.css header does not match the text domain used in
__()calls, translations never load. Double-check they match exactly.Forgetting to load the text domain: Simply using
__()in your code does not work unless you callload_theme_textdomain()orload_plugin_textdomain()to tell WordPress where your.mofiles are.
Practice Questions
What is the difference between
__()and_e()? When would you use each one?Why would you use
_x()instead of__()? Give an example where context matters.How does
_n()handle plural forms? What happens in languages with more than two plural forms?What is a text domain, and where must it be declared in a theme?
What is the difference between
.pot,.po, and.mofiles?
Challenge: Create a minimal plugin that registers a shortcode called [welcome_message]. The shortcode should display "Welcome, [username]!" using i18n functions with a text domain of welcome-plugin. Include proper text domain loading, translators comments, and create the plugin structure with a languages folder ready for .mo files. The plugin must handle pluralization for Visitor counts.
FAQ
Mini Project
Internationalize an existing theme with these steps:
Choose a minimal theme (or create one with 3โ4 template files). Identify every user-facing string.
Wrap every string in the appropriate i18n function (
__(),_e(),_n(),_x()). Add translators comments for any string with placeholders.Add the text domain to style.css header and create the
load_theme_textdomain()call in functions.php.Generate a
.potfile using WP-CLI or Poedit. Verify it contains all your strings.Create a French translation (
fr_FR.poandfr_FR.mo) translating at least 10 strings. Save the files in the languages folder.Switch your WordPress site language to French (Settings > General > Site Language). Verify your translated strings appear correctly.
Add an
rtl.cssfile with basic RTL overrides. Switch to Arabic and verify the layout flips correctly.
What's Next
Now that you can translate themes and plugins, move on to WooCommerce Setup to learn how to build an online store. Then explore Security Hardening to protect your multilingual site from common vulnerabilities.
For more development techniques, see the REST API tutorial to connect your translated site with frontend applications, and Hooks to understand the action and filter system that powers WordPress extensibility.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro