Internationalization

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

Internationalization (i18n) is the developer’s part of translating software: writing code so that every user-facing string can be replaced by a translation, and dates, numbers and text direction follow the reader’s language. Localization (l10n) is the translators' part: producing the translation files for one locale (es_ES, pt_BR, ar). This page, for plugin and theme developers, applies both to the lumen-catalog plugin of the Lumen Press site, translated into Spanish for a few strings. Running a site in another language is covered in Settings Screens and Updates and Auto-Updates; child theme translation is in Child Themes.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, WP-CLI 2.12.0, @wordpress/scripts 37.0.0 with @wordpress/i18n 7.0.0, and WordPress Coding Standards (WPCS) 3.4.1, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing on this page depends on it.

Core i18n translates the strings written in code (labels, messages, e-mails, block settings), not the content editors write. A multilingual site, with one copy of each post per language, needs a multilingual plugin or a Multisite network with one site per language; both rely on internationalized code.

Two locales are active on every request. The site locale (get_locale()) comes from Settings > General > Site Language, stored in the WPLANG option (empty for en_US). The user locale (get_user_locale(), since 4.7) comes from the Language field of the user’s profile, stored as the locale user meta, and falls back to the site locale. determine_locale() (5.0) decides which one a request uses: the user locale in the admin, REST requests from the editor and AJAX, the site locale on the front end, and the installer’s choice during installation.

The text domain

Every translatable string names a text domain, the identifier of the translation catalogue it belongs to. Core uses the default domain; a plugin or theme uses its own, which must equal its slug (the folder name): lumen-catalog for the plugin, lumen and lumen-child for the themes. WordPress.org builds language packs per slug, and the just-in-time loader looks for files named <domain>-<locale>, so a domain that differs from the slug is never translated. The domain is passed as a string literal in every call, never through a variable or constant, because the extraction tools read the source code without running it.

The main file declares it with two headers of the canonical header shown in Plugin Fundamentals. Text Domain: lumen-catalog is optional since 4.6 when it equals the slug, but wp i18n make-pot, Plugin Check and WPCS read it, so it is kept. Domain Path: /languages names the folder of the bundled translation files; without it, the plugin folder is used. WordPress looks for a domain’s files in this order and loads the first locale match: wp-content/languages/plugins/ (language packs from translate.wordpress.org), wp-content/languages/themes/, then the path registered for the domain (the Domain Path folder). A language pack therefore wins over a bundled file with the same name.

The gettext functions

The functions take the original English string, look it up in the domain’s loaded catalogue and return the translation, or the original when there is none. Those whose name contains _e echo instead of returning.

Function Use

__( $text, $domain )

Returns the translation. The basic function.

_e( $text, $domain )

Echoes it. Prefer the escaping variants below for output.

_x( $text, $context, $domain ), _ex()

Returns (or echoes) it, with a context that tells two identical English strings apart.

_n( $single, $plural, $number, $domain )

Returns the plural form that matches $number in the target language.

_nx( $single, $plural, $number, $context, $domain )

_n() with a context.

_n_noop(), _nx_noop() and translate_nooped_plural()

Register a plural pair now and translate it later, when the number is known.

esc_html__(), esc_html_e(), esc_html_x()

Translate and escape for HTML text.

esc_attr__(), esc_attr_e(), esc_attr_x()

Translate and escape for an HTML attribute value.

number_format_i18n( $number, $decimals )

Formats a number with the locale’s thousands separator and decimal point.

wp_date( $format, $timestamp ) (5.3)

Formats a date in the site time zone, with translated month and day names. Replaces date_i18n() for new code.

has_translation( $text, $domain ) (6.7)

Whether a translation exists, without loading the domain.

Placeholders and translator comments

Variable parts go in placeholders filled by sprintf() or printf(), never by concatenation: 'Published on ' . $date gives translators two fragments, and many languages need the date elsewhere in the sentence. A string with more than one placeholder uses numbered ones (%1$s, %2$s) so a translation can reorder them. A comment starting with translators: on the line directly above the call (or inside it, above the string) explains each placeholder; it is copied into the POT file and shown to translators next to the string. HTML stays out of strings where possible; when a link sits inside a sentence, the translation keeps the tag and the URL is inserted through a placeholder.

Plurals and context

_n() is not an if ( 1 === $n ). Each language defines its own plural rule in the Plural-Forms header of its translation file: Spanish has two forms (n != 1), Japanese one, Arabic six. The singular string still carries the placeholder, because some languages use the "singular" form for 21, 31 and so on. When the number is unknown at the point where the strings are declared (a post status label, a list registered on init), _n_noop() stores the pair and translate_nooped_plural( $noop, $count, 'lumen-catalog' ) picks the form later.

_x() adds a context to identical English strings that translate differently: the catalogue has a sort "Order" and an "Order" button, Orden and Pedir in Spanish, each a separate catalogue entry. Context also carries hints such as post type general name, used by core’s own labels.

Escaping translations

A translation is third-party input: a translator, or a compromised language pack, can put markup or script into it. Translated text is therefore escaped like any other output, late and for its context, with the combined functions (esc_html__(), esc_attr__(), esc_html_x()) or with esc_html( __() ); the catalogue’s search form prints its label with esc_html_e( 'Search the catalogue', 'lumen-catalog' ) and its placeholder attribute with esc_attr__( 'Title, author or ISBN', 'lumen-catalog' ). When a translation must contain HTML, wp_kses() keeps only the expected tags and the URL is escaped separately with esc_url() (see Security for Developers).

The catalogue strings

src/Text.php of lumen-catalog holds the user-facing strings of the catalogue and shows each case: a plural with a formatted number, reorderable placeholders, two contexts, a translated date and a sentence with a link. The class is loaded by the plugin’s Composer autoloader (see Plugin Fundamentals):

<?php
/**
 * User-facing catalogue strings.
 *
 * @package Lumen\Catalog
 */
namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

final class Text {

	public static function stock( int $copies ): string {
		return sprintf(
			/* translators: %s: number of copies in stock. */
			_n( '%s copy in stock', '%s copies in stock', $copies, 'lumen-catalog' ),
			number_format_i18n( $copies )
		);
	}

	/** "Title by Author", with numbered placeholders so translators can reorder them. */
	public static function byline( string $title, string $author ): string {
		return sprintf(
			/* translators: 1: book title, 2: author name. */
			__( '%1$s by %2$s', 'lumen-catalog' ),
			$title,
			$author
		);
	}

	public static function order_labels(): array {
		return array(
			'sort' => _x( 'Order', 'sort order of the list', 'lumen-catalog' ),
			'buy'  => _x( 'Order', 'button: buy this book', 'lumen-catalog' ),
		);
	}

	public static function published_on( int $timestamp ): string {
		return sprintf(
			/* translators: %s: publication date. */
			__( 'Published on %s', 'lumen-catalog' ),
			wp_date( get_option( 'date_format' ), $timestamp )
		);
	}

	/** Translated HTML: allow only the link, insert the URL separately. */
	public static function submissions_notice(): string {
		return sprintf(
			wp_kses(
				/* translators: %s: URL of the submissions page. */
				__( 'Read the <a href="%s">submission guidelines</a> before sending a manuscript.', 'lumen-catalog' ),
				array( 'a' => array( 'href' => array() ) )
			),
			esc_url( home_url( '/submissions/' ) )
		);
	}
}

Post type labels are translated the same way, inside the init callback that registers lp_book (_x( 'Books', 'post type general name', 'lumen-catalog' ) for name, __( 'Add New Book', 'lumen-catalog' ) for add_new_item; the full registration is in Custom Post Types and Taxonomies). The rewrite slug stays books in every language: a translated slug changes the URLs whenever the site language changes, and the rewrite rules are only flushed on activation.

Mistakes the tools catch

Five common mistakes, in a throwaway file Bad.php outside the plugin:

<?php
function lumen_catalog_bad_examples( string $title, int $count ): void {
	echo __( 'Added %s to your wishlist', 'lumen-catalog' );
	echo __( $title, 'lumen-catalog' );
	echo sprintf( __( '%s and %s', 'lumen-catalog' ), $title, $title );
	echo __( 'Books', 'lumen' );
	echo _n( 'One book', '%d books', $count, 'lumen-catalog' );
}

The WordPress.WP.I18n sniff of WPCS 3.4.1, run as vendor/bin/phpcs --standard=WordPress --sniffs=WordPress.WP.I18n --runtime-set text_domain lumen-catalog Bad.php, reported seven errors on five lines: MissingTranslatorsComment (lines 3, 5 and 7), NonSingularStringLiteralText ("The $text parameter must be a single text string literal. Found: $title", line 4), UnorderedPlaceholdersText (line 5, fixable automatically), TextDomainMismatch ("Expected 'lumen-catalog' but got 'lumen'", line 6) and MissingSingularPlaceholder (line 7).

__( $title ) can never work: the extractor cannot know the value, so no translator ever sees it. The real Text.php and Plugin.php passed the same sniff plus WordPress.Security.EscapeOutput with no reports. The text_domain property belongs in the plugin’s phpcs.xml.dist, next to the prefixes shown in Plugin Fundamentals.

Loading translations

How a plugin’s catalogue gets loaded changed several times, which is why older code is full of calls that are no longer needed:

Version Change

Before 4.6

Every plugin called load_plugin_textdomain(), usually on plugins_loaded, which read the .mo file immediately.

4.6

Just-in-time loading: the first translation call for a domain loads its file from wp-content/languages/plugins/. Plugins translated on translate.wordpress.org need no call.

6.5

Performant translations: .l10n.php files (plain PHP arrays, cached by OPcache) are preferred over .mo when both exist, through the new WP_Translation_Controller. The translation_file_format filter returns php or mo.

6.7

load_plugin_textdomain() and load_theme_textdomain() only register the path and defer loading to the just-in-time loader. Loading a domain before after_setup_theme triggers a "called incorrectly" notice.

6.8

Just-in-time loading extended to every plugin and theme, including those not on WordPress.org: while loading plugins, WordPress registers the Domain Path (or the plugin folder) of each active plugin’s Text Domain.

Since lumen-catalog declares Requires at least: 6.9, it calls no loading function at all: its Domain Path header is enough, and the first __( ..., 'lumen-catalog' ) after init loads languages/lumen-catalog-es_ES.l10n.php. A plugin that still supports WordPress older than 6.8 and ships its own files keeps a call, on init:

add_action(
	'init',
	static function () {
		load_plugin_textdomain( 'lumen-catalog', false, dirname( plugin_basename( LUMEN_CATALOG_FILE ) ) . '/languages' );
	}
);

Must-use plugins use load_muplugin_textdomain(), themes load_theme_textdomain().

The "triggered too early" notice

Translations loaded before the current user and locale are known come out in the wrong language (the site locale instead of the user’s profile language) and bypass any plugin that filters the locale or the file paths. Since 6.7, WordPress reports it. To reproduce it, a test copy of lumen-catalog.php gets one extra line at the end of the main file, which runs while WordPress is still including plugins:

// Too early: runs while the plugin file is included.
define( 'LUMEN_CATALOG_LABEL', __( 'Search Books', 'lumen-catalog' ) );

With WP_DEBUG enabled, any request (here wp eval 'echo LUMEN_CATALOG_LABEL, "\n";') prints:

Notice: Function _load_textdomain_just_in_time was called <strong>incorrectly</strong>. Translation loading for the
<code>lumen-catalog</code> 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 <code>init</code> action or later. Please see
<a href="https://developer.wordpress.org/advanced-administration/debug/debug-wordpress/">Debugging in WordPress</a> for
more information. (This message was added in version 6.7.0.) in /var/www/html/wp-includes/functions.php on line 6260
Buscar libros

The string was still translated, in the site locale, so the notice is easy to dismiss. On WordPress 7.1.3 it appeared even with the site in English, because since 6.8 every active plugin with a Text Domain header has a registered path, and the check runs before looking for a file. The usual causes are:

  • __() at the top level of a file, in a define(), a class constant or a property default, or in a constructor called while the plugin is included.

  • Registering post types, taxonomies, blocks or settings on plugins_loaded instead of init.

  • get_plugin_data( $file ) with the default $translate = true; pass false for the third argument when only the version is needed.

  • load_plugin_textdomain() itself on plugins_loaded is harmless since 6.7, but any translation call in the same callback is not.

The fix is to move the code into a callback on init (or later), or to translate at the point of output.

Translation files

File Role

lumen-catalog.pot

Template: every source string, with references, translator comments and flags, and empty translations. Generated from the code; never translated directly.

lumen-catalog-es_ES.po

The Spanish translation, edited by translators (in Poedit, on translate.wordpress.org or by hand). Not loaded by WordPress.

lumen-catalog-es_ES.mo

Binary compiled from the PO. Loaded when no PHP file exists.

lumen-catalog-es_ES.l10n.php

PHP array compiled from the PO (6.5+). Preferred over the MO: faster and cacheable by OPcache.

lumen-catalog-es_ES-<md5>.json

JavaScript strings of one script, in Jed format, named after the MD5 of the script’s relative path.

Generating the POT

wp i18n make-pot scans PHP, JavaScript, Blade, block.json and theme.json files and the plugin header. It defaults the domain to the slug and silently skips strings of other domains. Run from the plugin folder (in the cli container of the stack in Database Schema, with the plugin folder mounted):

wp i18n make-pot . languages/lumen-catalog.pot --exclude=src/admin --headers='{"Report-Msgid-Bugs-To":"https://lumenpress.example/support/"}'
Plugin file detected.
Success: POT file successfully generated.

node_modules, vendor and .git are excluded by default. --exclude=src/admin leaves out the JavaScript sources, so that the JavaScript strings are referenced from the built file that WordPress actually loads (see JavaScript translations). The entry for the script’s plural, with its translator comment and format flag:

#. translators: %d: number of books listed on this screen.
#: build/admin/catalog-screen.js:2
#, js-format
msgid "%d book on this page"
msgid_plural "%d books on this page"
msgstr[0] ""
msgstr[1] ""

The audit built into make-pot warns about placeholders without a translator comment and unordered placeholders (--skip-audit turns it off); with Bad.php copied into the plugin for a run, it printed Warning: The string "%s and %s" contains placeholders but has no "translators:" comment to clarify their meaning. followed by Warning: Multiple placeholders should be ordered. It does not see __( $title ) or the wrong domain, which is why WPCS runs as well.

A Spanish translation

The PO file starts as a copy of the POT with the language headers filled in; the Plural-Forms line must match the language. Translators then fill msgstr:

"Language-Team: Spanish (Spain)\n"
"Language: es_ES\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"

#: src/Text.php:33
msgctxt "sort order of the list"
msgid "Order"
msgstr "Orden"

#: src/Text.php:34
msgctxt "button: buy this book"
msgid "Order"
msgstr "Pedir"

#. translators: %s: number of copies in stock.
#: src/Text.php:16
#, php-format
msgid "%s copy in stock"
msgid_plural "%s copies in stock"
msgstr[0] "%s ejemplar en stock"
msgstr[1] "%s ejemplares en stock"

When the code changes, wp i18n update-po languages/lumen-catalog.pot languages/ merges new strings into existing PO files. The three compiled formats come from the PO:

cd languages
wp i18n make-mo lumen-catalog-es_ES.po
wp i18n make-php lumen-catalog-es_ES.po
wp i18n make-json lumen-catalog-es_ES.po --no-purge
ls -1

Each command printed Success: Created 1 file., leaving:

lumen-catalog-es_ES-ef813ae35541dd4fb413a45f5ec676cf.json
lumen-catalog-es_ES.l10n.php
lumen-catalog-es_ES.mo
lumen-catalog-es_ES.po
lumen-catalog.pot

make-json removes the JavaScript strings from the PO by default; --no-purge keeps the PO complete, so it can be compiled again. The .l10n.php file is a plain return [ ... ] of the headers and messages, with context and string joined by an invisible \x04 byte and plural forms by \0. With the MO file removed, the strings were still translated from the PHP file alone; a site that needs MO files only returns mo from translation_file_format.

Language packs from translate.wordpress.org

A WordPress.org plugin is translated by the community on translate.wordpress.org, and WordPress downloads each language pack into wp-content/languages/plugins/ with the other updates (translations update automatically by default; wp language plugin install <slug> <locale> installs one by hand). lumen-catalog is private, so it ships its own languages/ folder; a language pack for the same domain would take precedence.

Switching the locale

The site language is changed on Settings > General > Site Language (which installs the core language pack when the user may install languages) or with WP-CLI:

wp language core install es_ES
wp site switch-language es_ES

The commands printed Success: Installed 1 of 1 languages. and Success: Language activated., and the WPLANG option then held es_ES. wp site switch-language replaces the deprecated wp language core activate. Code switches temporarily with switch_to_locale() and goes back with restore_previous_locale(); loaded domains are reloaded for the new locale. A demo script, demo.php in the plugin folder, prints the catalogue strings in the site locale and in English:

<?php
// demo.php, run with: wp eval-file demo.php
use Lumen\Catalog\Text;

printf( "Locale: %s\n", determine_locale() );
echo Text::stock( 1 ), ' | ', Text::stock( 1250 ), "\n";
echo Text::byline( 'La casa del faro', 'Inés Durán' ), "\n";
echo implode( ' / ', Text::order_labels() ), "\n";
echo Text::published_on( mktime( 12, 0, 0, 3, 14, 2026 ) ), "\n";
echo Text::submissions_notice(), "\n";

switch_to_locale( 'en_US' );
printf( "Locale: %s\n", determine_locale() );
echo Text::stock( 1250 ), ' | ', Text::published_on( mktime( 12, 0, 0, 3, 14, 2026 ) ), "\n";
restore_previous_locale();
Locale: es_ES
1 ejemplar en stock | 1.250 ejemplares en stock
La casa del faro, de Inés Durán
Orden / Pedir
Publicado el marzo 14, 2026
Lee las <a href="https://lumenpress.test/submissions/">normas de envío</a> antes de enviar un manuscrito.
Locale: en_US
1,250 copies in stock | Published on March 14, 2026

number_format_i18n() used the Spanish thousands separator, and wp_date() translated the month. The odd "marzo 14, 2026" comes from the date_format option, F j, Y, which the installer stored in English; it is not translated when the site language changes later. A site installed in Spanish gets core’s translated default, j \d\e F \d\e Y ("14 de marzo de 2026"); an existing site changes it on Settings > General.

Messages in the recipient’s language

Code that writes to one user (e-mails, notifications) uses that user’s locale with switch_to_user_locale() (6.2), which also makes determine_locale() and get_user_locale() follow. Core already does this for its own e-mails, and since 6.7 also for messages to the admin_email address when it belongs to a user. With the site in Spanish and maria.editor set to English in her profile (wp user meta update maria.editor locale en_US):

<?php
// user-locale-demo.php, run with: wp eval-file user-locale-demo.php
use Lumen\Catalog\Text;

$editor = get_user_by( 'login', 'maria.editor' );

switch_to_user_locale( $editor->ID );
$subject = sprintf(
	/* translators: %s: book title. */
	__( 'New book awaiting review: %s', 'lumen-catalog' ),
	'La casa del faro'
);
echo $subject, ' | ', Text::stock( 3 ), "\n";
restore_previous_locale();
New book awaiting review: La casa del faro | 3 copies in stock

JavaScript translations

JavaScript uses the same functions from the @wordpress/i18n package: __(), _x(), _n(), _nx(), sprintf() and isRTL(). @wordpress/scripts treats @wordpress/* imports as externals: the build reads them from the global wp.i18n and lists wp-i18n in the generated .asset.php file, so the package is never bundled. The admin script of the Books list screen, src/admin/catalog-screen.js:

// Adds a stock summary under the Books list screen heading.
import { __, _n, sprintf, isRTL } from '@wordpress/i18n';
import domReady from '@wordpress/dom-ready';
import './catalog-screen.css';

domReady( () => {
	const heading = document.querySelector( '.wrap .wp-heading-inline' );
	if ( ! heading ) {
		return;
	}
	const rows = document.querySelectorAll(
		'#the-list tr.type-lp_book'
	).length;
	const summary = document.createElement( 'p' );
	summary.className = 'lp-catalog-summary';
	summary.textContent = sprintf(
		/* translators: %d: number of books listed on this screen. */
		_n(
			'%d book on this page',
			'%d books on this page',
			rows,
			'lumen-catalog'
		),
		rows
	);
	summary.title = isRTL()
		? __( 'Right-to-left layout', 'lumen-catalog' )
		: __( 'Left-to-right layout', 'lumen-catalog' );
	heading.after( summary );
} );

package.json pins @wordpress/scripts 37.0.0 as a development dependency (with @wordpress/i18n 7.0.0 and @wordpress/dom-ready 5.0.0 for the editor’s import resolution) and defines "build": "wp-scripts build src/admin/catalog-screen.js --output-path=build/admin". npm run build produced catalog-screen.js, catalog-screen.css, catalog-screen-rtl.css and catalog-screen.asset.php (dependencies wp-dom-ready and wp-i18n), and npm run lint:js reported nothing. The minifier keeps the __, _n calls and the translator comment, which is what lets make-pot read the built file.

The script is enqueued on the Books list only (edit.php with the lp_book screen), from a callback on admin_enqueue_scripts, and wp_set_script_translations() (5.0) tells WordPress to print the script’s translations before it. The relevant lines of Plugin::enqueue_admin():

$asset = require plugin_dir_path( LUMEN_CATALOG_FILE ) . 'build/admin/catalog-screen.asset.php';
wp_enqueue_script(
	'lumen-catalog-screen',
	plugins_url( 'build/admin/catalog-screen.js', LUMEN_CATALOG_FILE ),
	$asset['dependencies'],
	$asset['version'],
	array( 'in_footer' => true )
);
wp_set_script_translations( 'lumen-catalog-screen', 'lumen-catalog', plugin_dir_path( LUMEN_CATALOG_FILE ) . 'languages' );
wp_enqueue_style( 'lumen-catalog-admin', plugins_url( 'build/admin/catalog-screen.css', LUMEN_CATALOG_FILE ), array(), $asset['version'] );
wp_style_add_data( 'lumen-catalog-admin', 'rtl', 'replace' );

load_script_textdomain() looks for <domain>-<locale>-<handle>.json first, then <domain>-<locale>-<md5>.json, where the hash is the MD5 of the script’s path relative to the plugin folder. ef813ae35541dd4fb413a45f5ec676cf is the MD5 of build/admin/catalog-screen.js: had the POT referenced src/admin/catalog-screen.js, make-json would have produced a file for the source path, and the screen would have stayed in English with no error. A build that bundles several sources into one file maps them with make-json --use-map=build/map.json. On the Spanish site, wp_scripts()->print_translations( 'lumen-catalog-screen', false ) returned the inline script WordPress prints before the file: a call to wp.i18n.setLocaleData() for the lumen-catalog domain with the JSON content, including "%d book on this page":["%d libro en esta p\u00e1gina","%d libros en esta p\u00e1gina"].

Blocks need no call: when block.json has "textdomain": "lumen-catalog", register_block_type() calls wp_set_script_translations() for the block’s editor and view scripts that depend on wp-i18n, and translates the title, description, keywords and style labels of block.json on the server; make-pot extracts those fields too. Script modules, used by Interactivity API view scripts, gained wp_set_script_module_translations() in 7.0 for modules with their own domain. Block metadata and registration are covered in block.json and Registration.

Right-to-left languages

Arabic, Hebrew, Persian and Urdu are written right to left. is_rtl() returns true when the active locale’s core translation of the string ltr (context text direction) is rtl, so it depends on the core language pack, not on the plugin. In an RTL locale WordPress sets dir="rtl" on <html> (language_attributes()), loads the -rtl variant of core’s styles, and isRTL() returns true in JavaScript.

A plugin stylesheet needs its own RTL variant. @wordpress/scripts generates it with RTLCSS (catalog-screen-rtl.css swaps left and right):

/* src/admin/catalog-screen.css */
.lp-catalog-summary {
	margin-left: 0;
	padding-left: 12px;
	border-left: 4px solid #b3261e;
}
/* build/admin/catalog-screen-rtl.css, generated */
.lp-catalog-summary{border-right:4px solid #b3261e;margin-right:0;padding-right:12px}

wp_style_add_data( $handle, 'rtl', 'replace' ) makes WordPress load <file>-rtl.css instead of <file>.css when is_rtl() is true; the value true loads both. Block styles registered from block.json get this automatically when the -rtl.css file exists, and classic themes may ship an rtl.css that locale_stylesheet() adds. CSS logical properties (margin-inline-start, padding-inline-end, border-inline-start) avoid most of the swapping and suit new block themes. With the Arabic core pack installed (wp language core install ar) and switch_to_locale( 'ar' ), is_rtl() returned true and the handle printed a lumen-catalog-admin-rtl-css tag for catalog-screen-rtl.css; under es_ES it printed catalog-screen.css.

Pitfalls and corrections to older books

Pitfall or outdated advice Instead

load_plugin_textdomain() on plugins_loaded in every plugin, as older books and tutorials show

Not needed for WordPress.org plugins since 4.6, nor for any plugin with Text Domain and Domain Path headers since 6.8. When still needed for older versions, call it on init.

Translating strings at include time, in constants or on plugins_loaded

The 6.7 notice; strings come out in the site locale. Translate inside init or later callbacks.

A text domain that differs from the slug, or set in a variable or constant

Use the slug as a literal string in every call; otherwise neither language packs nor the extractor find the strings.

_n() used as a singular/plural if, or a singular string without its placeholder

Plural forms depend on the language; keep the placeholder in both forms.

Echoing translations unescaped (_e(), echo __())

esc_html_e(), esc_attr__(), wp_kses() for allowed HTML; translations are untrusted input.

Shipping only .mo files

Add .l10n.php files (wp i18n make-php), preferred since 6.5; keep the MO for older versions.

JavaScript strings passed from PHP with wp_localize_script()

@wordpress/i18n in the script plus wp_set_script_translations() and JSON files.

WordPress All-in-One For Dummies covers no development. Pro WordPress (written around 6.2) and Ultimate WordPress Handbook (6.4 to 6.6) predate the 6.7 notice and the 6.8 loader, so any translation-loading code modelled on them follows the earlier rules in the left column.