Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 055 / 100

Internationalization in WordPress: a step-by-step guide

Translation-ready code.

Expona Admin7 min read

Why internationalization matters in WordPress

Building a WordPress plugin or theme that only works in English limits your audience to roughly 25% of the world's internet users. Internationalization in WordPress: a step-by-step guide is about preparing your code so translators can adapt it to any language without touching a single line of PHP or JavaScript. When you build with i18n from the start, you're not just being polite to international users—you're multiplying your plugin's potential reach and demonstrating professional craftsmanship that clients and other developers respect.

The good news is that WordPress has built a robust system for handling translations, and it's not complicated once you understand the pattern. We'll walk through the concrete steps you need to take, from marking strings for translation to loading them in the browser, and finally getting them onto translate.wordpress.org so volunteer translators can do their work.

Understanding __() and _e() with a text domain

The foundation of WordPress internationalization rests on two simple functions: __() for retrieving translated strings and _e() for echoing them directly. Both require a text domain—a unique identifier that groups all your translatable strings together and prevents conflicts with other plugins or themes.

Here's a real-world scenario: you've built a custom e-commerce plugin called "QuickCart". Every user-facing string needs to be wrapped in either __() or _e() with your text domain. For a settings page, you might write:

php
add_action( 'admin_menu', function() {
    add_options_page(
        __( 'QuickCart Settings', 'quickcart' ),
        __( 'QuickCart', 'quickcart' ),
        'manage_options',
        'quickcart-settings',
        'quickcart_render_settings'
    );
} );

The second parameter in each __() call is your text domain. WordPress uses this to know which .pot (portable object template) file to extract strings from when building translation packs.

When you need to output text directly into HTML, use _e():

php
function quickcart_render_settings() {
    ?>
    <div class="wrap">
        <h1><?php _e( 'QuickCart Settings', 'quickcart' ); ?></h1>
        <form method="post">
            <label>
                <?php _e( 'Enable guest checkout', 'quickcart' ); ?>
                <input type="checkbox" name="guest_checkout" />
            </label>
        </form>
    </div>
    <?php
}

For strings that need variables or conditional formatting, use sprintf() combined with __():

php
$cart_count = 5;
$message = sprintf(
    __( 'You have %d items in your cart', 'quickcart' ),
    $cart_count
);
echo esc_html( $message );

This keeps the entire sentence as a single translatable unit, which is critical because word order changes between languages. Never split sentences like 'You have ' . __( '%d items', 'quickcart' ) because translators lose context.

Loading translations with load_plugin_textdomain

Marking strings for translation is only half the battle. WordPress also needs to know where your .mo (machine object) translation files live so it can load them at runtime. This is where load_plugin_textdomain() comes in.

You typically call this function early in your plugin's initialization, often in your main plugin file:

php
function quickcart_init() {
    load_plugin_textdomain(
        'quickcart',
        false,
        dirname( plugin_basename( __FILE__ ) ) . '/languages'
    );
}
add_action( 'init', 'quickcart_init' );

The three parameters are:

  • Text domain ('quickcart'): must match what you used in __() and _e() calls
  • Deprecated path (false): kept for backward compatibility, always use false for plugins
  • Directory path: relative path from your plugin root where translation files live

For themes, the pattern is slightly different. You'd hook into after_setup_theme instead:

php
function mytheme_setup() {
    load_textdomain(
        'mytheme',
        get_template_directory() . '/languages/' . is_admin() ? 'admin' : 'frontend' . '-' . get_locale() . '.mo'
    );
}
add_action( 'after_setup_theme', 'mytheme_setup' );

The translation files themselves follow a naming convention: quickcart-es_ES.mo for Spanish (Spain), quickcart-fr_FR.mo for French (France), and so on. WordPress automatically uses the active site language to load the correct file.

JavaScript translations with wp_set_script_translations

Modern WordPress plugins often handle frontend logic with JavaScript, particularly when using React or Vue components. The challenge is that your JS code also contains user-facing strings that need translation, but the traditional __() and _e() functions don't work in JavaScript.

WordPress 5.0+ introduced wp_set_script_translations() to solve this. The workflow is slightly more involved than PHP, but it follows a clear pattern.

First, in your plugin, use the @wordpress/i18n package in your JavaScript:

php
import { __ } from '@wordpress/i18n';

export function QuickCartCheckout() {
    return (
        <div className="checkout">
            <h2>{__( 'Order Summary', 'quickcart' )}</h2>
            <button>{__( 'Proceed to Payment', 'quickcart' )}</button>
        </div>
    );
}

Then, when you enqueue your script in PHP, tell WordPress to inject the translations into your JavaScript file:

php
function quickcart_enqueue_checkout() {
    wp_enqueue_script(
        'quickcart-checkout',
        plugins_url( 'build/checkout.js', __FILE__ ),
        array( 'wp-element', 'wp-i18n' ),
        '1.0.0',
        true
    );

    wp_set_script_translations(
        'quickcart-checkout',
        'quickcart',
        plugin_dir_path( __FILE__ ) . 'languages'
    );
}
add_action( 'wp_enqueue_scripts', 'quickcart_enqueue_checkout' );

When this runs, WordPress reads your translation files and injects a translation object into the global scope that your JavaScript can access. The __( 'string', 'quickcart' ) calls in your JS will then resolve to the translated strings automatically.

One common setup: if you're using a build tool like Webpack, make sure your build process extracts all __( 'string', 'domain' ) calls and includes them in your bundled JS. Tools like @wordpress/scripts handle this automatically if you're using WordPress's standard build setup.

Getting translations on translate.wordpress.org

Once your plugin is translation-ready, the next step is making it easy for volunteer translators to contribute. WordPress.org maintains translate.wordpress.org, a collaborative platform where translators can submit translations for any plugin or theme in the official repository.

To enable this, your plugin must be published on wordpress.org/plugins (or wordpress.org/themes). After you've submitted your plugin and it's approved, you simply need to generate a .pot file and commit it to your plugin's repository alongside your code.

Generate the .pot file using WP-CLI or a GUI tool like Poedit. With WP-CLI:

php
wp i18n make-pot . languages/quickcart.pot --domain=quickcart

This scans your entire plugin directory, finds all __() and _e() calls (including JavaScript ones if your build process has extracted them), and generates a template file. The .pot file looks like this:

php
#: admin/settings.php:24
msgid "QuickCart Settings"
msgstr ""

#: admin/settings.php:25
msgid "Enable guest checkout"
msgstr ""

Once you commit this file to your plugin repo, translate.wordpress.org automatically detects it and enables translation contributions. Within days or weeks, volunteer translators will start adding translations for Spanish, German, French, Portuguese, and many other languages—completely for free.

Your only job is to keep the .pot file updated. Every time you add new translatable strings, regenerate it before releasing a new version of your plugin. This ensures translators can see what's new and submit updated translations.

Common mistakes

Forgetting to use the same text domain everywhere: If you use 'quickcart' in your PHP but 'quick_cart' in your JavaScript, WordPress can't match them up. Pick one and stick with it across all files.

Breaking up sentences across multiple __() calls: Writing __( 'Total:', 'quickcart' ) . ' $' . __( '100', 'quickcart' ) makes translation nearly impossible because each piece is translated in isolation without context. Use sprintf() instead.

Not escaping translated output: Just because a string is translated doesn't mean it's safe to output. Always use esc_html(), wp_kses_post(), or similar escape functions around translated text.

Calling load_plugin_textdomain too late: If you hook it to plugins_loaded instead of init, you might miss early user-facing strings. Use init for plugins and after_setup_theme for themes.

Forgetting to run wp i18n make-pot before releasing: Your plugin will be marked as translation-ready on wordpress.org, but translators will have nothing to work with if you don't commit a .pot file.

Key takeaways

  • Wrap all user-facing strings with __() or _e(), always using the same text domain as a second parameter
  • Call load_plugin_textdomain() early in your plugin initialization, pointing to a languages directory where .mo files will live
  • For JavaScript, import __ from @wordpress/i18n and use wp_set_script_translations() to inject translations into your scripts
  • Generate and commit a .pot file to your plugin repository using WP-CLI's make-pot command before each release
  • Keep your .pot file up to date as you add new strings—this is how translators know what's new in each version
  • Always escape translated output with appropriate escape functions like esc_html()
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles