WordPress theme and plugin i18n: make code translatable
Make a WordPress theme or plugin translatable: text domain, __() and _n(), a POT file with WP-CLI, then load .mo, .json and .l10n.php files.
Using Claude Code, Codex or Cursor?
Connect PTC to your agent and it does this for you: it learns your app, asks you only what the code can't answer, and translates and reviews every language. One prompt:
Localize this app into German and Spanish with PTC.
Make your WordPress theme or plugin ready for translation. This guide covers text domains, gettext functions, POT generation, and the loading mechanism that puts translations in front of users. PTC (Private Translation Cloud) then translates the resource files and visually reviews the rendered theme or plugin in every language.
Want to translate a WordPress site you run, not code you wrote? Use WPML.
This guide is for developers writing the code. If you already have a POT or PO file and just need to translate it, head to the PO files page for the 3-step workflow.
By the end of this guide, your theme or plugin will be:
- Internationalized correctly per WordPress coding standards.
- Translatable by PTC into 40+ languages.
- Distributable via WordPress.org Language Packs.
- Verifiable on every release with PTC's visual translation review.
What is WordPress internationalization?
Internationalization (i18n) is the work of preparing your code so it can be translated. Localization (l10n) is the next step. It produces the actual translated strings for specific languages.
For WordPress themes and plugins, i18n means three things:
- Wrap every user-facing string in gettext functions so WordPress can swap them out at runtime.
- Define a text domain that ties your translations to your project.
- Generate a POT file that translators (or PTC) work from.
Once that work is done, you have everything you need to produce translated PO, MO, JSON, and .l10n.php files for any language.
PTC translates POT files into MO, JSON, and .l10n.php for WordPress
Five file extensions show up in any WordPress translation workflow. PTC translates from .pot (your source) into .po, .mo, .json, and .l10n.php for every target language. Each format has a specific role:
- POT (Portable Object Template). The source file generated from your code. It lists every translatable string with no translations attached. You hand this to PTC.
- PO (Portable Object). A copy of the POT with translations added for one specific language. Plain text, human-readable. PTC returns one PO per target language.
- MO (Machine Object). The compiled binary version of a PO. WordPress reads MO files at runtime because they load faster than PO text.
- JSON. The JavaScript equivalent of MO. WordPress cannot read MO from JavaScript, so the build pipeline produces JSON files for browser-side strings.
- .l10n.php. A newer alternative to MO, introduced in WordPress 6.5. It loads faster and uses less memory. WordPress picks it automatically when one exists alongside the MO file.
Enable .l10n.php for new projects. It is strictly better than MO on the supported WordPress versions.
Why community translation is not enough
WordPress.org offers community translation through GlotPress. In practice, it covers a small fraction of what most plugins and themes need. An analysis of more than 60,000 WordPress plugins and themes found that community translation covers under 5% of translation needs across 40 languages.
Two structural problems explain the gap:
- Volunteers are scarce in most locales. A handful of plugins with massive user bases attract translators. Most do not.
- Translations can take months or years to appear, if they appear at all. If you want users to see your plugin or theme in their language from day one, you cannot rely on the community.
This guide assumes you want consistent, full translation coverage on a release schedule you control. That is what PTC delivers.
Preparing your WordPress theme or plugin for translation
A mismatched text domain or an unwrapped string means that text will never appear in your translated output. The details matter.
Step 1: Define your text domain and domain path
Every theme or plugin needs a text domain. The text domain is a unique identifier that tells WordPress which translation files belong to your project. It must match your plugin or theme slug exactly.
Declare it in your plugin's main file header:
<?php
/**
* Plugin Name: My Plugin
* Description: An example plugin.
* Version: 1.0.0
* Text Domain: my-plugin
* Domain Path: /languages
*/
Or in your theme's style.css:
/*
Theme Name: My Theme
Text Domain: my-theme
Domain Path: /languages
*/
The domain path tells WordPress where your translation files live relative to the plugin or theme root. /languages is the standard.
Step 2: Wrap your PHP strings in gettext functions
Any string you want translatable needs to be wrapped in one of WordPress's gettext functions. At runtime, those functions look up the correct translation. If none is found, they fall back to the original string.
Basic strings. Use __() when you need to return a string. For HTML output, use the escaped variants. WordPress coding standards recommend echo esc_html__() over _e(). The escaped form makes output escaping explicit and prevents XSS at the point of output:
// Return a translated string
$label = __( 'Settings', 'my-plugin' );
// Echo a translated string, escaped for HTML
echo esc_html__( 'Settings saved.', 'my-plugin' );
Strings with variables. Do not concatenate variables into strings. Translators see only fragments and cannot reorder words for languages with different syntax. Use printf() or sprintf() with a placeholder. Add a translator comment so translators know what %s represents:
printf(
/* translators: %s: the user's display name */
esc_html__( 'Welcome back, %s.', 'my-plugin' ),
esc_html( $display_name )
);
Plural forms. English plurals are simple (one comment, two comments). Other languages are not. Use _n() to handle every plural rule WordPress knows about:
printf(
esc_html( _n( '%s comment', '%s comments', $count, 'my-plugin' ) ),
number_format_i18n( $count )
);
Strings that need context. Some words mean different things depending on where they appear. Use _x() to give translators the context they need:
// "Export" as a noun (the file) vs. a verb (the action)
echo esc_html_x( 'Export', 'button label', 'my-plugin' );
Step 3: Internationalize your JavaScript strings
WordPress provides the wp-i18n package so you can use the same gettext functions in JavaScript that you use in PHP. When registering your script, declare wp-i18n as a dependency:
wp_register_script(
'my-plugin-script',
plugins_url( 'js/app.js', __FILE__ ),
array( 'wp-i18n' ),
'1.0.0',
true
);
Then in your JavaScript file:
const { __, _n, sprintf } = wp.i18n;
const message = __( 'Settings saved.', 'my-plugin' );
If you use a bundler like Webpack, install @wordpress/babel-plugin-makepot. It extracts translatable strings from your bundle as part of your build.
Step 4: Generate your POT file
Once your strings are wrapped, generate a POT file. The POT is the source file PTC (or any translator) works from. It contains every translatable string but no translations.
wp i18n make-pot . languages/my-plugin.pot
WP-CLI scans your PHP, JavaScript, and block.json files for gettext calls. It compiles them into a single POT. If your team uses Composer, add this command as a Composer script. That keeps WP-CLI usage consistent across the team without a global install.
The full wp i18n command suite covers the rest of the pipeline:
| Command | What it does |
|---|---|
wp i18n make-pot |
Generate a POT file from source. |
wp i18n update-po |
Sync existing PO files when your POT changes. |
wp i18n make-mo |
Compile PO files into binary MO files. |
wp i18n make-json |
Extract JS strings from PO into JSON files. |
wp i18n make-php |
Generate .l10n.php files (WordPress 6.5+). |
You do not need Poedit. Everything in this workflow runs through WP-CLI and PTC. WP-CLI handles POT generation and MO/JSON compilation. PTC handles translation and returns every file format WordPress needs. Poedit is a useful desktop editor for manual translation, but it is not part of this workflow.
Translating POT files with PTC
PTC is built with WordPress developers in mind. Start with a POT file and get back production-ready translation files. The 30-day trial covers up to 20,000 words across two languages.
Set up your first translation project
Upload your POT file. Choose which output formats you need. PTC returns any combination of:
.pofiles for each target language..mofiles, compiled and ready to ship..jsonfiles for your JavaScript strings..l10n.phpfiles for faster loading on WordPress 6.5+.
The setup wizard asks you to describe your theme or plugin. PTC uses the description to generate translations with the right tone and context. Add brand-specific terms to the glossary at this stage. The glossary keeps names, feature labels, and any other terminology consistent across all languages.
PTC parses the gettext structure on upload. It recognizes placeholders (%s, %1$s, %d), plural forms (_n()-extracted entries with their Plural-Forms header), and contexts (_x() entries with msgctxt). It then generates the correct plural categories per language. Polish gets one / few / many / other. Japanese gets only other. Arabic gets six forms.
Move to continuous localization
Once your first files are translated, upgrade to Pay-As-You-Go. Connect PTC to your GitHub, GitLab, or Bitbucket repository. From that point you do not upload files manually.
Commit a .ptc-config.yml naming your source file and where the translations belong:
# .ptc-config.yml
source_locale: en
files:
- file: languages/my-plugin.pot
output: languages/my-plugin-{{lang}}.po
additional_translation_files:
- type: mo
path: languages/my-plugin-{{lang}}.mo
Then add the PTC Translate action to your workflow. It carries a pinned version of the PTC CLI inside itself, so your build downloads nothing at run time:
# .github/workflows/translate.yml
name: Regenerate POT for PTC
on:
push:
branches: [main]
paths:
- 'languages/my-plugin.pot'
workflow_dispatch: {}
jobs:
translate:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Generate POT
run: |
wp i18n make-pot . languages/my-plugin.pot --domain=my-plugin
- uses: OnTheGoSystems/ptc-action@v1
with:
api-token: ${{ secrets.PTC_API_TOKEN }}
create-pr: true
You have two ways to automate this. With Git integration, PTC watches your repository and opens a merge request with updated translations whenever the source file changes. To run translation inside your own CI job instead, use the PTC CLI - it uploads the changed file, waits for translation to finish, and downloads the translated files. Ready-made workflow templates for GitHub Actions, GitLab CI, and Bitbucket Pipelines are in the CI/CD pipeline setup guide.
Either flow delivers a merge request or download with the updated .po, .mo, .json, and .l10n.php files. See the PTC API reference for the full webhook and REST API.
Whether you commit .mo files to your repository depends on team policy. Many plugins generate them at release time instead. PTC's CI integration produces them on every translation run. Check them in only if you want translated files in version history.
Loading translations in WordPress
With translated files in hand, place them correctly and tell WordPress where to find them. The filename comes first. WordPress looks for translation files using a specific naming pattern. A filename that does not match means the file will not load.
Getting your filenames right
Locale codes follow the language_COUNTRY format. de_DE is German (Germany). fr_FR is French (France). pt_BR is Portuguese (Brazil). zh_CN is Simplified Chinese. The WordPress translation portal lists every supported locale.
The expected filename depends on where you place the file:
| Location | Pattern | Example |
|---|---|---|
Plugin's /languages/ folder |
{text-domain}-{locale}.mo |
my-plugin-de_DE.mo |
Theme's /languages/ folder |
{locale}.mo |
de_DE.mo |
Global WordPress language dir (/wp-content/languages/) |
{text-domain}-{locale}.mo |
my-plugin-de_DE.mo |
Themes use a shorter naming convention when files are bundled inside the theme. In the global language directory, both plugins and themes use the {text-domain}-{locale} pattern.
Loading translations for plugins (PHP)
Register translations with WordPress on the init hook. Do not use plugins_loaded. It triggers a deprecation warning in current WordPress versions:
add_action( 'init', function () {
load_plugin_textdomain(
'my-plugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages/'
);
} );
Loading translations for themes (PHP)
Use load_theme_textdomain() hooked to after_setup_theme:
add_action( 'after_setup_theme', function () {
load_theme_textdomain( 'my-theme', get_template_directory() . '/languages' );
} );
Loading JavaScript translations
After registering your script (Step 3 above), call wp_set_script_translations(). WordPress then loads the JSON translations for that script handle:
add_action( 'init', function () {
wp_set_script_translations(
'my-plugin-script',
'my-plugin',
plugin_dir_path( __FILE__ ) . 'languages'
);
} );
Verify the loading
- Set your WordPress site language to a target locale. The setting is at Settings > General > Site Language.
- Reload the front end and the admin pages your plugin or theme renders.
- Strings wrapped in
__()oresc_html__()should appear in the new language.
If something is missing, see WordPress plugin translations not showing? Fix missing translations for the most common causes.
Getting your translations to users: two paths
Once PTC has translated your files, you ship them in one of two ways.
Path 1: Publish translations through WordPress.org Language Packs (CLPTE)
Total time: 4-6 days.
Step 1: Create a CLPTE account
The CLPTE (Cross-Locale Project Translation Editor) role lets you submit translations on behalf of your theme or plugin across every target language from one account. A regular Translation Editor account is per-language; CLPTE is not.
If you already have an account on WordPress.org, you may hit an issue where your new CLPTE account is disabled. WordPress does not allow multiple accounts. To resolve, email forum-password-resets@wordpress.org and explain that you are trying to create a CLPTE account. Sharing the relevant Polyglots documentation with the moderator and pinging the Polyglots team in Slack both help.
Estimated time: ~3-5 days (includes waiting time if your new account is disabled).
Step 2: Request CLPTE access on the Polyglots blog
With your account enabled, post a request on the WordPress.org Polyglots blog. Follow their template:
PTE/CLPTE Request
Plugin URL: https://wordpress.org/plugins/my-plugin/
Authors / Editors: @your-wp-username
Type of request: CLPTE
You get a notification when your request is approved.
Estimated time: ~1 day (Polyglots Global Team reviews requests in batches).
Step 3: Import the translated .po files
- Log in to
https://translate.wordpress.org/using your CLPTE account. - Go to your plugin or theme's translation project page (for example,
translate.wordpress.org/projects/wp-plugins/{your-plugin-slug}/). - Open the local project for each language you have translated.
- Scroll down and click Import Translations.
- Upload your
.po(or.mo) file. - Mark the imported strings as “current” in the translation editor. Approved strings join the next Language Pack generation (typically daily).
- Repeat for every target language. Each locale has its own translation page.
Estimated time: ~10-30 minutes per language.
After a 24-48 hour Language Pack window, sites running your plugin start receiving the new translations through WordPress's automatic Language Pack updates. Users see no upgrade prompt. The translations just appear.
What CLPTE will and will not do for you
- You cannot replace existing community translations. Only untranslated strings are imported. Updating an existing community translation requires the standard process and waiting for the locale team's review.
- Updates require manual uploads for every release. Each new release means a new round of uploads.
- Account creation and approval typically take 4-6 days in total.
Path 2: Bundle the .mo files inside your plugin or theme
If you need translations live faster than CLPTE allows, or if you would rather not depend on WordPress.org's distribution pipeline, bundle the .mo files directly in your plugin or theme release. Total time: 15-30 minutes.
Step 1: Add the .mo files to the /languages/ directory
Place your compiled .mo files inside your plugin or theme's /languages/ directory so WordPress finds them when loading your text domain:
my-plugin/
├─ my-plugin.php
├─ readme.txt
├─ languages/
│ ├─ my-plugin-es_ES.mo
│ ├─ my-plugin-es_ES.po
│ ├─ my-plugin-fr_FR.mo
│ ├─ my-plugin-fr_FR.po
│ ├─ my-plugin-de_DE.mo
│ └─ my-plugin-de_DE.po
Use the same text domain in your translation file names and your load_plugin_textdomain() or load_theme_textdomain() calls.
Step 2: Prefer your bundled files over community translations
By default, WordPress prioritizes community translations from WordPress.org over bundled ones. To make sure your plugin or theme uses the bundled .mo files instead, add the load_textdomain_mofile filter.
For plugins, add to your main plugin file (e.g., my-plugin.php):
function my_plugin_load_embedded_translation_file( $mofile, $domain ) {
if ( 'my-domain' === $domain && false !== strpos( $mofile, WP_LANG_DIR . '/plugins/' ) ) {
$locale = apply_filters( 'plugin_locale', determine_locale(), $domain );
$local_mofile = WP_PLUGIN_DIR . '/' . dirname( plugin_basename( __FILE__ ) ) .
'/languages/' . $domain . '-' . $locale . '.mo';
if ( file_exists( $local_mofile ) ) {
return $local_mofile;
}
}
return $mofile;
}
add_filter( 'load_textdomain_mofile', 'my_plugin_load_embedded_translation_file', 10, 2 );
For themes, add to your theme's functions.php:
function my_theme_load_embedded_translation_file( $mofile, $domain ) {
if ( 'my-domain' === $domain && false !== strpos( $mofile, WP_LANG_DIR . '/themes/' ) ) {
$locale = apply_filters( 'theme_locale', determine_locale(), $domain );
$local_mofile = get_template_directory() . '/languages/' . $locale . '.mo';
if ( file_exists( $local_mofile ) ) {
return $local_mofile;
}
}
return $mofile;
}
add_filter( 'load_textdomain_mofile', 'my_theme_load_embedded_translation_file', 10, 2 );
Also confirm your plugin or theme loads the text domain on the right hook:
add_action( 'init', function() {
load_plugin_textdomain(
'my-plugin',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
} );
Step 3: Verify the bundled translations load on a test site
Install your plugin or theme on a test site and switch WordPress to one of the languages you have provided:
- Go to Settings > General > Site Language and select a language whose
.mofile is in your/languages/folder. - Load a page or screen that contains strings you know are translated.
- Confirm the strings appear in the correct language and match the translations you added.
If you still see old community translations, review the snippet in Step 2. It needs to correctly point WordPress to your bundled .mo files.
Commit the .mo files, tag a release, and push to wordpress.org via SVN as you normally do.
The trade-off between speed and canonical distribution
- Pro: instant. New translations are live the moment users update your plugin (or install it for the first time). No multi-day wait for CLPTE. No Polyglots-team approval cycle.
- Pro: you control the workflow. Translation cadence matches your release cadence exactly.
- Pro: no plugin-badge dependency. Translations work whether or not your plugin has CLPTE permissions.
- Con: plugin file size grows. Each
.mois small (5-50KB typically), but 20 languages add ~500KB to the download. - Con: no auto-update of translations between plugin releases. With CLPTE, translations updated on wordpress.org propagate to users within 48 hours, even if you do not ship a new release. With bundling, translations only update when you ship a new release.
- Con: no “translated” badge. WordPress.org displays a translation-completion badge on plugin listings based on Language Pack coverage, not bundled
.mofiles. Bundling alone does not earn the badge.
Which path matches your release cadence?
| Situation | Recommendation |
|---|---|
| Long-term canonical distribution; you want the translation badge | CLPTE - go through the Polyglots application |
| You ship plugin updates monthly or more often, and translation cadence can match | Bundle MO files |
| You need translations live this week and cannot wait for CLPTE | Bundle MO files now, apply for CLPTE in parallel |
| You want both | Use both - they are not mutually exclusive |
The dual approach is common: bundle MO files for immediate availability, and submit to CLPTE for canonical distribution. Users get the bundled translations on update; the Language Pack overlay refreshes them between releases.
Right-to-left languages and bidirectional layouts
The translation workflow is the same for right-to-left (RTL) languages. Arabic, Hebrew, Persian, and Urdu use the same POT/PO/MO pipeline.
The extra step is making sure your theme supports RTL styles. WordPress automatically loads an rtl.css file if one exists in your theme directory. Use the is_rtl() function to conditionally apply RTL-specific styles or scripts.
Localizing dates, numbers, and currencies
A translated string is not the whole story. Dates, numbers, and currencies must also follow the user's locale. Use WordPress's built-in formatting functions instead of PHP's native ones:
date_i18n()formats dates according to the active locale.number_format_i18n()formats numbers with locale-aware decimal and thousands separators.
These functions are not part of the translation file workflow. They matter for a fully localized experience.
Internationalizing Block Editor (Gutenberg) blocks
Blocks add two extra steps to the standard pipeline:
- JavaScript translations need a
.jsonfile per locale. Generate it from the.powithwp i18n make-json. - The block's editor script needs
wp_set_script_translations()in PHP. That tells WordPress to serve the JSON to the block.
The block-rendered output on the front end uses the same __() calls as your other PHP. No extra work there.
Translating your README and WordPress.org listing
Your readme.txt is not a resource file. WP-CLI will not pick it up when generating a POT. To translate it, use PTC's Paste to Translate feature. Paste the content, choose your target languages, and download the result. Customer-facing emails sent from the plugin and the WordPress.org plugin page description translate the same way, all in the same project so terminology stays consistent.
If your plugin or theme is listed on WordPress.org, the translated description appears on the plugin's Details tab in the user's language. To get translations live there, follow the WordPress.org import process.
Translate plugin user content with the PTC API
Plugins that store user-generated data (forum plugins, review plugins, comment plugins) can translate that content as it arrives. The PTC REST API translates user posts, comments, and reviews on demand with Bearer-token authentication, using the same glossary and brand voice as your .po files.
Visual translation review of your rendered theme or plugin - ship without manual QA per language
A translated .po file is necessary, but it is not sufficient. The translated theme or plugin still needs verification:
- A translated label may overflow a settings-page button in German.
- “Submit” may translate as a noun in French when the admin action needed a verb.
- A hardcoded English string outside
__()will render untranslated regardless of how many languages you ship.
PTC's visual translation review replaces the manual QA pass. WordPress themes and plugins render in the browser (both wp-admin and the front end). The right flavor is the browser extension.
Install it once. Record a walkthrough of your theme or plugin on a test site. Cover settings pages, admin actions, and front-end output. PTC replays the recording in every target language after every translation update. It captures every screen and reports back two kinds of fixes:
- Fixes in the
.pofiles when PTC controls them. PTC re-translates a wrong sense, picks a shorter synonym that fits a button, or regenerates a plural form. - Cursor or Claude Code prompts when the issue lives in your PHP or JavaScript code. Examples include a missing
__()wrapper, a hardcoded English string, or a sentence built by concatenation that should usesprintf( __( ... ) ).
You ship a verified, multilingual plugin per release. The manual QA tail goes away.
Pricing: 30-day trial, then Pay-As-You-Go
The trial covers 20,000 words into 2 languages with no credit card. When the trial ends, PTC offers Pay-As-You-Go. No subscription. No minimum commitment. The first 500 words every month are free. You only pay for the rest. The pricing page has a cost calculator. Sign up with a company email for an extended business trial.
Ready to ship a verified plugin or theme?
PTC generates the translations and reviews the rendered plugin. You confirm the result and release. The full loop runs without manual QA:
- Generate your
.potfile withwp i18n make-pot. - Upload it to PTC and get back
.po,.mo,.l10n.php, and.jsonfiles in minutes. - Install the browser extension to verify the running plugin in every target language.
Start your 30-day trial - 20,000 words on us, no credit card required.
Related:
- Translate PO files online with AI - the canonical PO/POT translation page.
- GlotPress vs PTC for WordPress plugin/theme translation - when to choose community translation vs PTC.
- WordPress plugin translations not showing? Fix missing translations - troubleshooting guide.
- How to import theme and plugin translations into WordPress.org - CLPTE process and a faster alternative.
- PTC API reference - REST endpoints for CI integration.
Last updated: