Internationalisation is making a plugin translatable. Localisation is someone actually translating it. We do the first one, and if we do it wrong the second one is impossible. This covers text domains, when translations load, the PHP functions, and getting strings and data into JavaScript, where the 2022 advice has aged worst.
The text domain
A text domain is a unique string that tells WordPress which group of strings belongs to our plugin. It must match the plugin slug.
* Text Domain: my-basics-plugin
* Domain Path: /languagesSince WordPress 4.6 the Text Domain header is optional, because it has to equal the slug anyway. Domain Path only matters when the plugin bundles its own translation files somewhere other than its own folder. A plugin in the WordPress.org directory can leave it out.
Mismatch the slug and translations from WordPress.org never load, with no error to tell us why.
When translations load
For a plugin hosted on WordPress.org, load_plugin_textdomain() is not needed at all. Since 4.6, WordPress loads the plugin’s translations just in time, the first time a string from that domain is requested.
Since 6.7 that timing is enforced. Ask for a translated string before after_setup_theme and WordPress logs a notice instead of quietly loading early:
Translation loading for the my-basics-plugin domain was triggered too early.
This is usually an indicator for some code in the plugin or theme running too early.
Translations should be loaded at the init action or later.The file format changed too. Since 6.5, WordPress looks for a .l10n.php translation file before the .mo, because PHP files load faster. wp i18n make-php builds them from .po files, and the translation_file_format filter switches back to .mo if needed.
The PHP functions
__( 'String', 'text-domain' ); // returns
_e( 'String', 'text-domain' ); // echoes
esc_html__( 'String', 'text-domain' ); // returns, escaped for HTML
esc_attr__( 'String', 'text-domain' ); // returns, escaped for an attributeTwo rules matter more than the function list:
- escape at output. A translated string is still untrusted input, because we did not write the translation
- use placeholders, never string concatenation
Concatenating is the one that actively breaks translation. Word order differs by language, and a translator handed half a sentence cannot fix it:
printf(
/* translators: %s: movie title */
esc_html__( 'Now showing: %s', 'movie-library' ),
esc_html( $title )
);The comment has to start with translators: and be the last PHP comment before the call, or the string extractor will not pick it up.
JavaScript strings
Import __ from @wordpress/i18n, which is how every block label gets translated. Then declare wp-i18n as a script dependency and register the translations:
wp_register_script( 'movie-library-editor', $url, array( 'wp-i18n' ), $version );
wp_set_script_translations( 'movie-library-editor', 'movie-library' );WordPress then looks for a JSON translation file for that handle, including the ones from translate.wordpress.org. An optional third argument points it at a local directory. wp i18n make-json builds those files from a .po, one per JavaScript source file.
Two more cases:
- a block registered from
block.jsonwith atextdomainfield needs no call. Core callswp_set_script_translations()for each of its scripts that depends onwp-i18n - script modules, the ES module kind the Interactivity API uses, got translation support in 7.0. A plugin module with its own text domain registers it with
wp_set_script_module_translations()
Passing data to JavaScript
The August version of this post said wp_localize_script() is for data, not translations. The function reference says the opposite: wp_add_inline_script() is now the best practice for data, and wp_localize_script() should only be used to localize strings. For strings, wp_set_script_translations() does it better. So the function named localize has one job left, and even that one has a replacement.
There is a technical reason too. wp_localize_script() runs every top-level scalar through (string) before encoding, so 'postId' => 123 arrives in JavaScript as "123", and true arrives as "1".
Older code
wp_localize_script()
- prints
var name = {...}before the script - top-level numbers and booleans become strings
- the reference now reserves it for localizing strings
Chosen
wp_add_inline_script()
- we write the JavaScript ourselves
wp_json_encode()keeps the types- the reference calls it best practice for data
wp_add_inline_script(
'movie-library-editor',
'const movieLibrary = ' . wp_json_encode(
array(
'restUrl' => rest_url( 'movie-library/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'postId' => get_the_ID(),
)
) . ';',
'before'
);'before' prints it ahead of the script tag, so movieLibrary exists by the time our code runs.
What this does not cover
Plural forms with _n(), context with _x(), generating .pot files with wp i18n make-pot, and the naming rules for JavaScript translation files, which are their own small pile of md5 hashes.

Leave a comment