Internationalisation: translating themes and plugins

Wrap every user-facing string so it can be translated, handle plurals and context, and load translations in PHP and JavaScript.

  • Intermediate
  • 8 min read
  • Updated 17 Oct 2026

What you will learn

  • Make strings translatable
  • Handle placeholders, plurals and context correctly
  • Load translations
  • Translate strings in JavaScript
  • Generate translation files with WP-CLI

Before you start

Most WordPress sites are not in English. If your plugin or theme prints hard-coded English, every one of those sites sees English in the middle of their content. Internationalisation (often shortened to i18n) means writing code so its text can be translated. Localisation is the translation itself, usually done by other people. Your job as a developer is the first part, and it costs very little if you do it from the start.

Text domains

Every translatable string belongs to a text domain: a unique name that tells WordPress which translation file to look in. Use your plugin or theme slug: the folder name, in lowercase, with hyphens rather than underscores between words. Declare it in the plugin header (or the theme's style.css):

PHP
<?php
/**
 * Plugin Name: My Plugin
 * Text Domain: myplugin
 * Domain Path: /languages
 */

Then pass the domain, as a plain string, to every translation function. It must be a literal, not a variable or constant, because the tools that extract strings read your source code without running it.

The core functions

FunctionDoes
__( $text, $domain )Returns the translation
_e( $text, $domain )Echoes the translation
_x( $text, $context, $domain )Returns the translation, with context for translators
_n( $single, $plural, $number, $domain )Returns the singular or plural form
_nx( $single, $plural, $number, $context, $domain )Plural with context
esc_html__(), esc_html_e(), esc_html_x()Translate, then escape for HTML
esc_attr__(), esc_attr_e(), esc_attr_x()Translate, then escape for an attribute
PHP
<?php

$label = __( 'Upcoming events', 'myplugin' );
?>
<h2><?php esc_html_e( 'Upcoming events', 'myplugin' ); ?></h2>
<input type="search" placeholder="<?php esc_attr_e( 'Search events', 'myplugin' ); ?>">

Prefer the escaping versions whenever you print. A translation is data from a file you did not write, and the sanitising lesson's rule applies: escape late.

Placeholders

Never build sentences by joining pieces. Word order differs between languages, and translators only see the fragments:

PHP
<?php

// Wrong: "at" and the order are fixed in English.
echo esc_html__( 'Starts at', 'myplugin' ) . ' ' . esc_html( $time );

// Right: one sentence, with a placeholder.
printf(
    /* translators: %s: event start time, for example 7:30 pm. */
    esc_html__( 'Starts at %s', 'myplugin' ),
    esc_html( $time )
);

The /* translators: */ comment, placed on the line directly before the function call, is copied into the translation file so translators know what %s means.

With more than one placeholder, number them so translators can reorder:

PHP
<?php

printf(
    /* translators: 1: event name, 2: venue name. */
    esc_html__( '%1$s at %2$s', 'myplugin' ),
    esc_html( $event_name ),
    esc_html( $venue_name )
);

Use single quotes for strings containing $ placeholders, or PHP will try to read $s as a variable.

Plurals

Languages have different plural rules: some have one form, some have three or more. _n() lets the translation file pick the right one:

PHP
<?php

$count = count( $events );

printf(
    /* translators: %s: number of events. */
    esc_html( _n( '%s event', '%s events', $count, 'myplugin' ) ),
    esc_html( number_format_i18n( $count ) )
);

_n() chooses the form; printf() still inserts the number. number_format_i18n() formats the number for the site's locale, for example 1,250 or 1.250. For dates, wp_date() formats a timestamp in the site's time zone with translated month and day names.

Context

The same English word can need different translations. "Post" as a noun and "Post" as a verb are different words in many languages. _x() adds a context that keeps them apart:

PHP
<?php

$noun = _x( 'Post', 'noun: a blog post', 'myplugin' );
$verb = _x( 'Post', 'verb: publish a comment', 'myplugin' );

Common mistakes

PHP
<?php

// Variables cannot be extracted: translators never see the text.
__( $message, 'myplugin' );

// Variable text domain: the tools cannot tell which file this belongs to.
__( 'Save', $domain );

// HTML inside the string: translators can break it, and you cannot escape it.
__( '<strong>Note:</strong> bookings close at noon.', 'myplugin' );

When a string needs markup, keep the markup outside, or allow only specific tags with wp_kses() after translating. Keep strings as whole sentences, without leading or trailing spaces.

Loading translations

When translations are loaded

Translation files are loaded the first time a string from that domain is translated, which happens on or after the init action. Calling __() earlier, for example at the top of the plugin file or on plugins_loaded, can load translations too early, and current versions of WordPress show a "doing it wrong" notice when that happens. Register post types, taxonomies and anything else with translated labels on init, as earlier lessons did.

Plugins and themes from WordPress.org

For plugins and themes hosted on WordPress.org, translations are made by the community on translate.wordpress.org and installed automatically into wp-content/languages/. WordPress loads them just in time, so you do not need any loading code.

Bundling your own translations

For a private or commercial plugin, ship the files in your languages folder and tell WordPress where they are:

PHP
<?php

add_action( 'init', 'myplugin_load_textdomain' );

function myplugin_load_textdomain(): void {
    load_plugin_textdomain( 'myplugin', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}

A theme does the same with load_theme_textdomain( 'mytheme', get_template_directory() . '/languages' ), called from its setup function.

Translation files

FileContains
.potThe template: every source string, no translations. Generated from your code
.poOne language's translations, editable by people and translation tools
.moThe compiled version of a .po file that WordPress reads
.l10n.phpThe same translations as a PHP array, which current versions of WordPress load faster than .mo
.jsonTranslations for one JavaScript file

Files are named after the text domain and locale, for example myplugin-de_DE.po.

WP-CLI generates all of them:

Shell
# Extract every string from PHP, JavaScript and block.json files.
wp i18n make-pot . languages/myplugin.pot

# After translators deliver languages/myplugin-de_DE.po:
wp i18n make-mo languages
wp i18n make-php languages
wp i18n make-json languages --no-purge

make-pot reads the translators: comments too. Run it as part of your build so the template never goes stale.

JavaScript translations

Scripts can use the same functions through the wp-i18n package. Declare it as a dependency, then tell WordPress the script needs translations:

PHP
<?php

add_action( 'wp_enqueue_scripts', 'myplugin_enqueue_calendar' );

function myplugin_enqueue_calendar(): void {
    wp_enqueue_script(
        'myplugin-calendar',
        plugins_url( 'assets/js/calendar.js', __FILE__ ),
        array( 'wp-i18n' ),
        '1.0.0',
        array( 'strategy' => 'defer' )
    );

    wp_set_script_translations(
        'myplugin-calendar',
        'myplugin',
        plugin_dir_path( __FILE__ ) . 'languages'
    );
}
JavaScript
// assets/js/calendar.js
const { __, _n, sprintf } = wp.i18n;

const heading = __( 'Upcoming events', 'myplugin' );
const count = document.querySelectorAll( '.myplugin-event' ).length;

const summary = sprintf(
    /* translators: %d: number of events. */
    _n( '%d event this week', '%d events this week', count, 'myplugin' ),
    count
);

If you build with @wordpress/scripts, write import { __, _n, sprintf } from '@wordpress/i18n'; instead; the build adds the wp-i18n dependency for you. wp i18n make-json creates one JSON file per script from the .po file, and wp_set_script_translations() loads the right one. Block translations work the same way, with the text domain set in block.json, which the Gutenberg development course covers.

Testing

Switch the site language under Settings > General, or install a language and switch with WP-CLI:

Shell
wp language core install de_DE --activate

Strings that stay in English after you load a translation usually have a typo in the text domain, were translated before init, or were never wrapped.

Summary

  • Wrap every user-facing string with __(), _e(), _x(), _n() or their escaping versions, using a literal text domain that matches your slug.
  • Use placeholders with printf() and translators: comments instead of joining fragments, and number them when there are several.
  • Use _n() for plurals and _x() for context; format numbers and dates with number_format_i18n() and wp_date().
  • Translate on or after init; WordPress.org projects load translations automatically, others call load_plugin_textdomain().
  • Generate .pot, .mo, .l10n.php and .json files with wp i18n, and load JavaScript translations with wp_set_script_translations().

Further reading