Building a WordPress plugin without internationalization (i18n) is like building a house in only one language—it limits your reach and frustrates users globally. Yet many plugin developers either skip i18n entirely or implement it incorrectly, discovering too late that their text domain doesn't match, their translation strings use variables, or critical strings simply aren't translatable. These mistakes fragment your user base and make maintenance a nightmare when translators report "untranslatable" strings or when you need to update copy across multiple languages.
This guide walks you through WordPress plugin internationalization step by step. You'll learn the core gettext functions, how to set up text domains properly, generate POT files for translators, and avoid the mistakes that derail most plugin localization efforts. By the end, you'll have a translation-ready plugin that reaches users in their native language.
Table of Contents
- What is i18n and Why It Matters
- Core Translation Functions
- Understanding Text Domains
- Generating POT Files
- Working with PO/MO Files
- Common i18n Mistakes and Fixes
- Testing Your Translations
- WP HealthKit's i18n Detection
- Frequently Asked Questions
What is i18n and Why It Matters
i18n is shorthand for internationalization—the "i" and "n" mark the first and last letters, with 18 letters in between. It refers to the practice of building software that adapts to different languages and regional preferences without code changes.
In WordPress, internationalization is built on gettext, the GNU standard for handling translations. Every translatable string in your plugin gets wrapped in a translation function like __() or _e(). This function looks up the string in a translation file (MO file) and returns either the translated version or the original English if no translation exists.
The Market for Non-English WordPress
The business case for i18n is compelling. According to W3Techs, WordPress powers over 40% of the web, and over 50% of WordPress installations are in non-English markets. That means approximately 100 million WordPress sites operate in languages other than English. Spanish-speaking countries represent some of the fastest-growing WordPress markets. China, Japan, Germany, France, and India have massive WordPress user bases. If your plugin is limited to English, you're excluding hundreds of millions of potential users. For commercial plugins, this translates to massive lost revenue. A plugin with 10,000 English-speaking users could potentially serve 50,000+ users globally with proper i18n.
Translation also improves SEO in non-English markets. Search engines evaluate content relevance and quality partially based on language consistency. A site translated into proper Spanish will rank better in Spanish search results than one using English interface elements. E-commerce stores, SaaS platforms, and service-based plugins see direct increases in conversions when users can interact in their native language.
Community Integration and WordPress.org Approval
Beyond reach, proper i18n makes your code more maintainable. Instead of hardcoding "Save Settings" across five different files, you define it once and reference it throughout. This reduces duplication, makes future copy changes easier, and ensures consistency across your interface.
Most importantly, the WordPress community expects i18n. Plugin reviewers check for it before approving plugins for the official directory. Users on the WordPress translation platform (translate.wordpress.org) contribute thousands of translations daily—including for languages your team doesn't even speak. Without i18n, you're not just limiting your reach—you're excluding yourself from community-driven growth. Thousands of translators spend their free time making WordPress plugins available in their languages. That volunteer effort can launch your plugin into markets you couldn't reach otherwise.
Core Translation Functions
WordPress provides a family of translation functions. Each serves a specific purpose, and using the right one depends on whether you're storing, echoing, or escaping the translated string.
__() - Retrieve Translated String
The foundation function. It retrieves the translated version of a string and returns it. Use this when you need to store, manipulate, or build strings programmatically.
<?php
$message = __( 'Welcome to our plugin', 'my-plugin-domain' );
echo esc_html( $message );
// In conditional logic
if ( $setting === 'yes' ) {
$status = __( 'Enabled', 'my-plugin-domain' );
} else {
$status = __( 'Disabled', 'my-plugin-domain' );
}
?>
_e() - Echo Translated String
A convenience function that retrieves and immediately echoes the translated string. Use this for direct output.
<?php
_e( 'Settings saved successfully', 'my-plugin-domain' );
echo '<p>';
_e( 'This plugin requires PHP 7.4 or higher', 'my-plugin-domain' );
echo '</p>';
?>
_x() - Retrieve Translated String with Context
Some strings have identical English text but different meanings in different languages. Use _x() to provide context for translators.
<?php
// "View" as a verb (look at something)
$verb = _x( 'View', 'view as verb', 'my-plugin-domain' );
// "View" as a noun (a scenic outlook)
$noun = _x( 'View', 'view as noun', 'my-plugin-domain' );
// "Post" as content type vs mail
$type = _x( 'Post', 'content type', 'my-plugin-domain' );
$mail = _x( 'Post', 'postal mail', 'my-plugin-domain' );
?>
_n() - Pluralization
Handles singular and plural forms. This is critical because plural rules differ across languages—English has 2 forms, Polish has 5.
<?php
$count = count( $items );
$message = _n(
'%d item',
'%d items',
$count,
'my-plugin-domain'
);
echo sprintf( esc_html( $message ), $count );
?>
Escaping Variants
For security, WordPress provides escaping versions of translation functions. Use these when outputting directly to HTML.
<?php
// Safe for HTML content
echo '<p>' . esc_html__( 'Click the button below', 'my-plugin-domain' ) . '</p>';
// Echo and escape for HTML
esc_html_e( 'Username is required', 'my-plugin-domain' );
// Escape for HTML attributes
echo '<input type="text" placeholder="' . esc_attr__( 'Enter your name', 'my-plugin-domain' ) . '">';
?>
For a deeper look at escaping in the context of XSS prevention, see our dedicated guide.
Understanding Text Domains
The text domain is your plugin's unique identifier in the translation system. Every translatable string must include a text domain, and WordPress uses it to load the correct translation file, prevent conflicts with other plugins, and keep your strings separate from WordPress core.
Choosing and Registering Your Text Domain
Your text domain should match your plugin's directory name:
<?php
/**
* Plugin Name: My Awesome Plugin
* Plugin URI: https://example.com/my-plugin
* Text Domain: my-awesome-plugin
* Domain Path: /languages
*/
add_action( 'plugins_loaded', function() {
load_plugin_textdomain(
'my-awesome-plugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
});
?>
Text Domain Best Practices
Use the same domain consistently. Every __(), _e(), _x() call must use the identical text domain:
<?php
// CORRECT: Consistent domain
_e( 'Save Settings', 'my-awesome-plugin' );
_e( 'Delete Plugin', 'my-awesome-plugin' );
// WRONG: Domain variations (won't be found by translation tools)
_e( 'Save Settings', 'my-awesome-plugin' );
_e( 'Delete Plugin', 'my_awesome_plugin' ); // Underscore vs hyphen
_e( 'Delete Plugin', 'my-awesome-plugin-v1' ); // Version appended
?>
Store language files in a languages/ folder:
my-awesome-plugin/
├── plugin.php
├── includes/
├── admin/
└── languages/
├── my-awesome-plugin.pot
├── my-awesome-plugin-es_ES.po
├── my-awesome-plugin-es_ES.mo
├── my-awesome-plugin-fr_FR.po
└── my-awesome-plugin-fr_FR.mo
Optimize Your Plugin Scans
Before deploying, audit your plugin for missing text domains and translation function errors. WP HealthKit identifies untranslatable strings, missing text domains, and i18n inconsistencies — catching problems translators would otherwise report after submission.
Generating POT Files
A POT file (Portable Object Template) is the master template containing every translatable string in your plugin. Translators use it as the source to create translations for specific languages.
POT Generation with WP-CLI
The easiest method is using WP-CLI:
# Navigate to your plugin directory
cd wp-content/plugins/my-awesome-plugin
# Generate the POT file
wp i18n make-pot . languages/my-awesome-plugin.pot
WP-CLI scans your PHP files, identifies all translation function calls, and generates a POT file with proper formatting.
What a POT File Contains
# Translation of My Awesome Plugin
msgid ""
msgstr ""
"Project-Id-Version: My Awesome Plugin 1.0\n"
"Report-Msgid-Bugs-To: [email protected]\n"
"POT-Creation-Date: 2026-03-18 10:30:00+0000\n"
#: includes/class-settings.php:42
msgid "Save Settings"
msgstr ""
#: admin/dashboard.php:15
msgctxt "view as verb"
msgid "View"
msgstr ""
#: admin/users.php:22
msgid "%d user"
msgid_plural "%d users"
msgstr[0] ""
msgstr[1] ""
Each entry includes a comment with file location and line number, the original English string (msgid), the translated string (msgstr, empty in POT), and optional context (msgctxt) or plural forms.
Automating POT Generation
Add a script to your package.json for easy regeneration:
{
"scripts": {
"i18n": "wp i18n make-pot . languages/my-awesome-plugin.pot --exclude=vendor,node_modules"
}
}
Regenerate after every feature release that adds or changes user-facing strings. The workflow should be: develop new feature, wrap strings in translation functions, run the i18n script, commit the updated POT file with your code changes. This ensures translators always have the latest strings to work with. Many plugin teams automate this in CI/CD—on every release, regenerate the POT file and commit it automatically.
Real-World Translation Workflows
Understanding how translations actually happen helps you design your i18n better. When you submit your plugin to translate.wordpress.org, community translators can see your POT file and begin translating immediately. A translator might be fluent in Spanish and WordPress but have no experience with your specific plugin—they're translating purely based on the strings you've provided. If your strings are ambiguous or poorly structured, they'll request clarification through the GlotPress comments interface. If strings are hardcoded without i18n functions, translators can't touch them at all.
Some developers mistakenly think they should provide translations themselves. This is inefficient. A professional Spanish translator will produce better translations than a developer who is fluent in English and somewhat familiar with Spanish. Community translators also catch nuances and cultural considerations that automated translations miss. For example, a phrase that's technically correct in Spanish might sound stilted or formal to a native speaker. Professional translators make your plugin feel native to each language.
Working with PO/MO Files
Once you have a POT file, translators create language-specific PO (Portable Object) files. You then compile these into MO (Machine Object) files for WordPress to use.
PO File Structure
A Spanish translation (my-awesome-plugin-es_ES.po) looks like the POT but with translations filled in:
#: includes/class-settings.php:42
msgid "Save Settings"
msgstr "Guardar Configuración"
#: admin/users.php:22
msgid "%d user"
msgid_plural "%d users"
msgstr[0] "%d usuario"
msgstr[1] "%d usuarios"
Compiling MO Files
MO files are binary compiled versions of PO files that WordPress actually reads. Generate them with:
# Compile a single PO file
msgfmt languages/my-awesome-plugin-es_ES.po \
-o languages/my-awesome-plugin-es_ES.mo
# Or use WP-CLI to compile all PO files
wp i18n make-mo languages/
Using GlotPress for Team Translations and Community Contribution
GlotPress is a web interface for managing translations collaboratively. With GlotPress, translators don't need to understand PO file syntax—they use a web interface, and you export compiled MO files. If your plugin is in the official directory, translate.wordpress.org provides this automatically. The interface is intuitive: translators see your English strings and provide translations for their language. Context hints (from the _x() function with its second parameter) help them understand nuanced meanings. Project maintainers can review translations before they're finalized, ensuring quality and consistency.
The beauty of GlotPress is that it removes technical barriers. A Spanish translator doesn't need git knowledge or understanding of MO files. They just contribute translations through the web interface. WordPress handles the compilation and distribution. For your plugin, this means that translations can happen in parallel to your development. While you're building v2.0, translators are working on v1.9 translations. By the time you release, those languages might already have community translations waiting.
Common i18n Mistakes and Fixes
Most i18n mistakes fall into a few categories: structural (concatenation, missing domains), functional (missing or incorrect escaping), and cultural (not accounting for language-specific considerations). Understanding these patterns helps you avoid them in your own code.
Mistake 1: Concatenating Strings in Translation Functions
<?php
// WRONG: String concatenation inside __()
$message = __( 'User ' . $username . ' logged in', 'my-plugin' );
// CORRECT: Use placeholders and sprintf()
$message = sprintf(
__( 'User %s logged in', 'my-plugin' ),
esc_html( $username )
);
?>
Translation tools can't identify concatenated strings as translatable. Translators only see literal strings in __(), _e(), and similar functions.
Mistake 2: Missing Text Domain
<?php
// WRONG: No text domain
echo __( 'Welcome to the plugin' );
// CORRECT: Always include text domain
echo __( 'Welcome to the plugin', 'my-plugin' );
?>
Without a text domain, WordPress doesn't know which translation file to use, and translation tools ignore the string.
Mistake 3: Inconsistent Text Domains
<?php
// WRONG: Different domains for the same plugin
_e( 'Save', 'my-plugin' );
_e( 'Delete', 'my_plugin' );
_e( 'Update', 'my-awesome-plugin' );
// CORRECT: Same domain everywhere
_e( 'Save', 'my-plugin' );
_e( 'Delete', 'my-plugin' );
_e( 'Update', 'my-plugin' );
?>
Mistake 4: Not Escaping Translated Strings
<?php
// WRONG: Unescaped output
echo __( 'Welcome message', 'my-plugin' );
// CORRECT: Use escaping variants
echo esc_html__( 'Welcome message', 'my-plugin' );
?>
Translated content is user-facing output and needs escaping against XSS attacks.
Mistake 5: Plural Forms Without _n()
<?php
// WRONG: Manual plural logic
if ( $count === 1 ) {
echo __( '1 item in your cart', 'my-plugin' );
} else {
echo __( '%d items in your cart', 'my-plugin' );
}
// CORRECT: Use _n() for proper plural handling
echo sprintf(
_n(
'%d item in your cart',
'%d items in your cart',
$count,
'my-plugin'
),
$count
);
?>
Different languages have different plural rules. Spanish uses singular/plural. Polish has five forms. Arabic has six. _n() handles this automatically through the translation framework.
Mistake 6: Missing Text Domain in Plugin Header
<?php
/**
* WRONG: Missing text domain header
* Plugin Name: My Awesome Plugin
* Description: Does awesome things
*/
/**
* CORRECT: Includes text domain and domain path
* Plugin Name: My Awesome Plugin
* Description: Does awesome things
* Text Domain: my-awesome-plugin
* Domain Path: /languages
*/
?>
The plugin header tells WordPress and translation tools where to find translations.
Testing Your Translations
Before releasing your plugin, verify that your translations work correctly.
Set Up a Test Language
Create a test locale with prefixed strings to easily spot untranslated text:
cp languages/my-awesome-plugin.pot languages/my-awesome-plugin-xx_XX.po
# Edit the PO file: prefix each msgstr with "XXX "
# msgid "Save Settings"
# msgstr "XXX Save Settings"
Change WordPress Locale
<?php
// In wp-config.php for testing
define( 'WPLANG', 'xx_XX' );
?>
Visit your plugin's pages and check that strings have the test prefix. Missing prefixes indicate strings not properly wrapped in translation functions.
Check with WP-CLI
# Validate the POT file
wp i18n validate-pot languages/my-awesome-plugin.pot
# Check translation statistics
wp i18n stats languages/
WP HealthKit's i18n Detection
WP HealthKit automatically scans your plugin for translation issues that would frustrate users and translators. Before you submit to WordPress.org or distribute your plugin, catch these i18n problems that will otherwise derail translators.
What WP HealthKit Detects
Missing Text Domains: Strings wrapped in __() or _e() without the second text domain parameter. Without a text domain, WordPress can't load the correct translation file, and your translated strings never appear.
Inconsistent Domains: Multiple text domain variations within the same plugin (e.g., my-plugin vs my_plugin). This creates fragmented translation sets where some strings translate and others don't, creating a confusing user experience and wasting translator effort.
Concatenated Strings: Translation functions containing concatenation or variable interpolation that translation tools can't extract. A translator looking at your POT file sees the code structure, not the dynamic result. This makes translation impossible and frustrates professional translators who will mark your plugin as non-translatable.
Unescaped Translation Output: Translated strings echoed without proper escaping functions. This is a security vulnerability—translated content from GlotPress should be treated with the same escaping care as user-generated content. XSS attacks through translation strings are rare but possible.
Missing Translation Functions: Hardcoded English strings in user-facing output that should be wrapped in translation functions. These are the strings translators can't reach, limiting your plugin's global usability.
Running an i18n Audit
Upload your plugin to WP HealthKit for a comprehensive scan. We analyze your entire codebase and report i18n issues alongside security vulnerabilities, performance problems, and coding standards violations across 17 verification layers.
Frequently Asked Questions
What's the difference between POT, PO, and MO files?
POT files are templates containing all translatable strings. PO files are human-editable translations for specific languages. MO files are compiled binary versions of PO files that WordPress reads at runtime. You create POT, translators edit PO, you compile to MO.
Can I use sprintf() with translation functions?
Yes, and you should. Use sprintf() with placeholders to insert variables after translation, not before:
<?php
$message = sprintf(
__( 'Hello %s', 'my-plugin' ),
esc_html( $username )
);
?>
What if a translator reports an "untranslatable" string?
Usually means the string is concatenated or includes variables inside the translation function. Fix it by using sprintf() with placeholders, then regenerate your POT file.
Should I use esc_html__() or __() followed by esc_html()?
Both work, but esc_html__() is more concise for direct output. Use __() when you're storing the result and escaping later.
How often should I regenerate POT files?
After every feature release that adds or changes user-facing strings. Include the regenerated POT file in your commit.
Can I use HTML in translation strings?
Avoid it. Translations should be plain text. Use placeholder strings if you need HTML:
<?php
$message = sprintf(
__( 'Click %1$shere%2$s to proceed', 'my-plugin' ),
'<a href="' . esc_url( $url ) . '">',
'</a>'
);
echo wp_kses( $message, array( 'a' => array( 'href' => array() ) ) );
?>
Conclusion
WordPress plugin internationalization isn't optional—it's the foundation of a plugin that reaches global users and integrates with the WordPress ecosystem. Proper i18n means wrapping every user-facing string with translation functions, using consistent text domains, generating POT files for translators, and testing before release.
The most common mistakes—concatenating variables into translations, forgetting text domains, and mixing domains—are all preventable with careful coding habits. Pair manual discipline with automated scanning to catch i18n issues before translators report them.
Start with your next update. Audit your existing code, add translation functions to any hardcoded strings, and generate your first POT file. Your global audience awaits.
Ready to Audit Your Plugin?
WP HealthKit scans for missing text domains, inconsistent i18n implementation, and untranslatable strings across your entire codebase.
Check your plugin now → — Free analysis, no credit card required.