Internationalization

Wrap every user-facing string in a translation function with your text domain, and let WordPress load the translations.

Syntax__( string $text, string $domain = 'default' ): string

Internationalization means running every human-readable string through a lookup so a translator can replace it. WordPress uses gettext. Each string is a key; each locale ships a compiled catalogue keyed by your text domain, a slug that must match the Text Domain header of your plugin or theme.

The function family

__( $text, $domain ) returns the translation. _e() is shorthand for echo __() — the same thing. Because translations are just as untrusted as any other data, prefer the escaping variants: esc_html__( $text, $domain ) is exactly esc_html( __( $text, $domain ) ), and esc_html_e() echoes it. esc_attr__() and esc_attr_e() do the same for attributes.

_n( $single, $plural, $number, $domain ) picks a plural form — languages with three or six forms need the count, not an if. _x( $text, $context, $domain ) disambiguates a word like "Post" that is a noun in one place and a verb in another.

Loading and timing

load_plugin_textdomain( $domain, $deprecated, $plugin_rel_path ) registers where your .mo files live. Since 4.6 translations from WordPress.org load automatically, and since 6.7 nothing loads immediately — the catalogue is fetched just in time, on the first call. The consequence is a timing rule: calling a translation function before the init action triggers a _doing_it_wrong() notice. Register strings inside callbacks, not at file scope.

Example

Example · php
<?php
/**
 * Plugin Name: SC Bookings
 * Text Domain: sc-bookings
 * Domain Path: /languages
 *
 * The header's Text Domain must match the slug used in every call below.
 */

/**
 * Point WordPress at the plugin's own .mo files.
 * Since WP 6.7 this only registers the path; loading happens on first use.
 */
function scb_load_textdomain(): void {
	load_plugin_textdomain(
		'sc-bookings',
		false, // deprecated parameter, always false
		dirname( plugin_basename( __FILE__ ) ) . '/languages'
	);
}
add_action( 'init', 'scb_load_textdomain' );

/**
 * Render the seat count. Called on the front end, so run it through
 * esc_html at the same time.
 */
function scb_render_seats( int $seats ): void {
	printf(
		'<p>%s</p>',
		esc_html(
			sprintf(
				/* translators: %s: number of seats still available. */
				_n( '%s seat left', '%s seats left', $seats, 'sc-bookings' ),
				number_format_i18n( $seats ) // localises the digits and separators
			)
		)
	);
}

When to use it

  • Prepare a plugin for the WordPress.org directory, which requires a text domain.
  • Show "1 item" and "5 items" correctly in languages with more than two plural forms.
  • Fix a 6.7 "translation loading triggered too early" notice in the debug log.
  • Translate labels used by a React-based block in the editor.

More examples

Every shorthand next to its longhand

Reading someone else's plugin you will meet all of these forms; they desugar to the plain __() plus an escaper. The numbered placeholders in the last block matter because word order differs between languages — a translator cannot swap %s and %s, but can swap %1$s and %2$s.

Example · php
// These pairs are identical. The left form is what you will see in core.
_e( 'Settings saved.', 'sc-bookings' );
echo __( 'Settings saved.', 'sc-bookings' );

esc_html_e( 'Settings saved.', 'sc-bookings' );
echo esc_html( __( 'Settings saved.', 'sc-bookings' ) );

$label = esc_attr__( 'Close dialog', 'sc-bookings' );
$label = esc_attr( __( 'Close dialog', 'sc-bookings' ) );

// _nx() is _n() and _x() combined: plural form plus gettext context.
$text = _nx( '%s post', '%s posts', $count, 'blog entries', 'sc-bookings' );

// Placeholders belong INSIDE the translated string so translators can
// reorder them. Never concatenate around a translation.
printf(
	/* translators: 1: user display name, 2: booking reference. */
	esc_html__( '%1$s booked reference %2$s.', 'sc-bookings' ),
	esc_html( $user->display_name ),
	esc_html( $reference )
);

Translator comments that actually help

The extractor copies a /* translators: */ comment into the .pot file only when it is immediately above the gettext call, so placement is load-bearing. Use _x() whenever the same English string needs different translations in different places — without it both usages collapse into one entry and one language will read wrong.

Example · php
// Bad: the translator sees "%s of %s" with no idea what either is.
printf( esc_html__( '%s of %s', 'sc-bookings' ), $done, $total );

// Good: the comment must sit on the line directly above the call,
// start with "translators:", and be a /* */ comment, not //.
printf(
	/* translators: 1: bookings completed, 2: bookings total. */
	esc_html__( '%1$s of %2$s', 'sc-bookings' ),
	number_format_i18n( $done ),
	number_format_i18n( $total )
);

// Context solves a different problem: one English word, two meanings.
$noun = _x( 'Book', 'a printed volume', 'sc-bookings' );
$verb = _x( 'Book', 'reserve a seat', 'sc-bookings' );
// These become two separate entries in the .pot file.

Passing strings to JavaScript

Block editor code cannot call PHP, so translations are shipped as JSON files and injected before your script runs. Two things break this in practice: forgetting wp-i18n in the dependency array, and forgetting that the JSON files are generated from the .po files by WP-CLI rather than existing automatically.

Example · php
function scb_enqueue_admin_script(): void {
	wp_enqueue_script(
		'scb-admin',
		plugins_url( 'build/admin.js', __FILE__ ),
		array( 'wp-i18n' ), // the JS __() lives in this package
		'1.0.0',
		true
	);

	// Tells WordPress to inline the JSON translations for this handle.
	// Requires .json language files generated by "wp i18n make-json".
	wp_set_script_translations(
		'scb-admin',
		'sc-bookings',
		plugin_dir_path( __FILE__ ) . 'languages'
	);
}
add_action( 'admin_enqueue_scripts', 'scb_enqueue_admin_script' );

/* In admin.js:
   import { __, _n, sprintf } from '@wordpress/i18n';
   const label = __( 'Add booking', 'sc-bookings' );
*/

Discussion

  • Be the first to comment on this lesson.