Security and quality
Internationalisation and Coding Standards for Plugins
Get a plugin's strings in front of translators with the right text domain, placeholder comments and script translations, then hold the line with PHPCS.
A plugin with eighty strings, translated by volunteers into eleven languages, changes one label
from Notes to Notes and comments. The English site is fine. In the German,
Japanese and Portuguese sites the label is still Notes, and the string is missing
from the translation interface. Nothing in the plugin broke. The string is not translatable any more,
because it was changed in a way that makes the translation file that was already written stop
matching.
Internationalisation in a plugin is mostly bookkeeping, and the bookkeeping has three parts: a text domain that matches the plugin slug, function calls that tell a translator what they are looking at, and output formatting that respects the site's locale. The coding standards are the other half of the same problem, because they are what catches a missing domain before a translator does. Neither part is hard. Both are easy to skip for a release and expensive to retrofit.
The text domain has to be the plugin slug
The text domain is the string you pass as the last argument to every translation function. On translate.wordpress.org it is also the identifier that decides which language packs a plugin receives: the directory builds its translation sets from plugin slugs, and a plugin whose text domain does not match its slug gets no translations at all, however diligently the strings are wrapped.
The slug is the WordPress.org plugin directory slug, not the display name and not the main file
name. A plugin called WPSifero Notes whose directory is wpsifero-notes uses
wpsifero-notes everywhere: in every call, in the Text Domain plugin header,
and in the Domain Path header when the .mo files ship inside the plugin.
/**
* Plugin Name: WPSifero Notes
* Text Domain: wpsifero-notes
* Domain Path: /languages
*/
// Consistent domain, or the string is invisible to translators.
__( 'Saved the note.', 'wpsifero-notes' )
A text domain has to be a literal string in the call. Passing a variable or a constant makes the
string invisible to the parser that extracts it, which is why
__( 'Saved.', WPSIFERO_NOTES_DOMAIN ) looks tidy and produces an empty translation
catalogue. The one exception the tooling understands is a class constant in a small number of forms,
and it is not worth relying on.
load_plugin_textdomain is now a fallback, not a ritual
For years every plugin began by calling load_plugin_textdomain() on
plugins_loaded. Since WordPress 4.6 the loader also looks in
wp-content/languages/plugins/, which is where language packs from
translate.wordpress.org are installed, so a plugin that ships no .mo files of its own does
not need the call. Since 6.7 the function no longer loads immediately at all: per its own changelog it
hands the path to the just-in-time loading mechanism, which loads a domain the first time a string
from it is requested.
You still need it in one situation: the plugin ships its own translations inside its directory, typically a bundled language or a commercial plugin that cannot use the directory's language packs. Then the call tells the text domain registry where to look.
add_action( 'init', 'wpsifero_notes_load_textdomain' );
function wpsifero_notes_load_textdomain(): void {
// Only for a plugin that ships .mo files in /languages.
load_plugin_textdomain( 'wpsifero-notes', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
The deprecated second argument is still accepted and produces a _deprecated_argument()
notice. Pass false and the relative path. If the plugin is on WordPress.org and ships no
language files, remove the call rather than leaving it in as decoration.
__ returns, _e prints, _x disambiguates, _n pluralises
Four functions cover almost everything, and picking the wrong one is a code smell PHPCS will
report. __() returns a translated string, which is what you want in a variable, an array
or a sprintf(). _e() echoes it, and it is only correct when the escaping has
already happened inside the call or when the string is safe by construction.
// Returns, so it can be escaped at the point of output.
$label = __( 'Notes', 'wpsifero-notes' );
echo '<h1>' . esc_html( $label ) . '</h1>';
// Echoes the translation already escaped for HTML.
esc_html_e( 'Notes', 'wpsifero-notes' );
_x() takes a context before the domain and exists for the strings English cannot
disambiguate. Post as a noun and Post as a verb are the same four
characters; so are Notes the screen title and Notes the column heading.
Without a context, a translator has one string and two meanings to guess between.
_n() takes the singular, the plural, the count and the domain, and it is not the same
thing as a conditional. Translators supply the plural rule, and plenty of languages have more than two
forms, so 1 === $count ? $one : $many bypasses the rule they wrote.
$notice = sprintf(
/* translators: %s: number of notes that were imported. */
_n( '%s note imported.', '%s notes imported.', $count, 'wpsifero-notes' ),
number_format_i18n( $count )
);
Plural forms need all three of singular, plural and count to be reached at runtime. Passing a
computed count is fine; passing the string '%s' as the count is how a plugin reports
"1 notes imported" in every language.
A placeholder without a translator comment is a bug in waiting
Every string containing %s, %d or %1$s is a sentence with a
hole in it, and the translator sees only the sentence. They cannot see what fills the hole, whether
it is a name, a number, a date, or a filename with a trailing slash that needs a different preposition
in their language. The comment immediately above the call, starting with the exact word
translators:, is how that information travels.
sprintf(
/* translators: %s: the title of the note that was saved. */
esc_html__( 'Saved the note "%s".', 'wpsifero-notes' ),
esc_html( $title )
);
Two rules make the comment useful rather than decorative. It must sit directly above the
translation call, with no statement between the comment and the call, because that is where the
extraction tools look. And it must name every placeholder in the string, including each numbered one
in strings that reorder them. PHPCS enforces both as
WordPress.WP.I18n.MissingTranslatorsComment and, for the placeholders, as a mismatch
between the documented arguments and the ones in the string.
Numbered placeholders are the better choice when a string has two of them. A translator who has to
write the note title after the date needs %1$s and %2$s to swap them; with
plain %s the order is fixed by the English sentence.
Numbers and dates are formatted at output, not at storage
number_format() puts a comma every three digits and a full stop before the decimals,
which is correct in the United States and wrong in Germany, France and most of South America. Store
raw values, format at the point of display with the two functions WordPress provides.
// 1234.6 as a price, in the site's locale.
echo esc_html( number_format_i18n( 1234.6, 2 ) );
// A timestamp formatted in the site's timezone, for the site's locale.
echo esc_html( wp_date( (string) get_option( 'date_format' ), $last_saved ) );
date_i18n() is the older function for the same job and still works;
wp_date() is the one to reach for in new code because it takes a
DateTimeZone and translates month and day names through the same catalogue. Both take the
format string from the site's settings rather than hard-coding 'Y-m-d', because the
format is a user preference. Both also deserve get_option( 'date_format' ) with a
sensible default rather than the date() call that PHPCS's
WordPress.DateTime.RestrictedFunctions flags, since date() reads the PHP
timezone while WordPress renders in the site timezone.
JavaScript needs its own translation file attached to a handle
Strings in your admin script are not covered by the PHP catalogue. They need a JSON file, and the file is matched to the script by name and by an explicit registration call.
add_action( 'admin_enqueue_scripts', 'wpsifero_notes_enqueue_admin' );
function wpsifero_notes_enqueue_admin( string $hook_suffix ): void {
if ( 'tools_page_wpsifero-notes' !== $hook_suffix ) {
return;
}
wp_enqueue_script(
'wpsifero-notes-admin',
plugins_url( 'assets/admin.js', __FILE__ ),
array( 'wp-i18n' ),
'1.0.0',
true
);
wp_set_script_translations( 'wpsifero-notes-admin', 'wpsifero-notes', plugin_dir_path( __FILE__ ) . 'languages' );
}
The file is named {domain}-{locale}-{handle}.json, so the handle in
wp_set_script_translations() and the handle in wp_enqueue_script() have to be
the same string, and the script has to depend on wp-i18n to have the runtime that
consumes it. In the script, wrap the strings the same way:
import { __, sprintf } from '@wordpress/i18n';
const message = sprintf(
/* translators: %s: number of notes in the export. */
__( '%s notes in this export.', 'wpsifero-notes' ),
count
);
What the call actually does is attach the domain and path to the registered handle; the JSON is
fetched when the script is printed. Without an en-locale JSON file in the build, nothing
is printed and the strings fall back to the source text, which is exactly what the plugin's own test
can assert. One limit worth knowing: the Japanese and Chinese language packs commonly ship
.mo files and no JSON, in which case core generates the JSON from the
.mo on the fly, so a missing file is not always a bug in your plugin.
Three PHPCS findings cover most of a new plugin
PHPCS is the tool that turns all of the above from discipline into a build step. Installing it in a
plugin means a phpcs.xml.dist in the plugin root and the WordPress standards available to
the binary.
<?xml version="1.0"?>
<ruleset name="WPSifero Notes">
<description>Coding standards for the WPSifero Notes plugin.</description>
<file>.</file>
<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<exclude-pattern>/tests/*</exclude-pattern>
<arg name="basepath" value="."/>
<arg name="extensions" value="php"/>
<arg name="colors"/>
<arg value="ps"/>
<config name="testVersion" value="8.3-"/>
<config name="minimum_wp_version" value="7.0"/>
<rule ref="WordPress-Core"/>
<rule ref="WordPress-Docs"/>
<rule ref="WordPress-Extra"/>
<rule ref="WordPress.WP.I18n">
<properties>
<property name="text_domain" type="array">
<element value="wpsifero-notes"/>
</property>
</properties>
</rule>
</ruleset>
The three standards are not interchangeable. WordPress-Core is the handbook ruleset
with the whitespace and naming conventions. WordPress-Docs is the inline documentation
ruleset that requires the file, class, function and hook docblocks.
WordPress-Extra adds the best practices beyond the handbook, which is where the
date() and escaping rules live. Keeping testVersion and
minimum_wp_version in the file matters more than it looks: they are what let the
sniffs tell a real incompatibility from a false positive.
Run against the plugin for this module, PHPCS reported nine findings on six lines of one deliberately bad file. Three of them show up in almost every new plugin.
$ vendor/bin/phpcs --report=full --basepath=.
FILE: src/Admin_Page.php
FOUND 8 ERRORS AND 1 WARNING AFFECTING 6 LINES
1 | ERROR | Filenames should be all lowercase with hyphens as word
| | separators. Expected admin-page.php, but found Admin_Page.php.
| | (WordPress.Files.FileName.NotHyphenatedLowercase)
1 | ERROR | Class file names should be based on the class name with
| | "class-" prepended. Expected
| | class-wpsifero-notes-admin-page.php, but found Admin_Page.php.
| | (WordPress.Files.FileName.InvalidClassFileName)
10 | ERROR | Missing doc comment for class WPSifero_Notes_Admin_Page
| | (Squiz.Commenting.ClassComment.Missing)
12 | ERROR | Missing doc comment for function render()
| | (Squiz.Commenting.FunctionComment.Missing)
13 | ERROR | date() is affected by runtime timezone changes which can cause
| | date/time to be incorrectly displayed. Use gmdate() instead.
| | (WordPress.DateTime.RestrictedFunctions.date_date)
15 | ERROR | All output should be run through an escaping function ...
15 | ERROR | Missing $domain parameter in function call to __().
| | (WordPress.WP.I18n.MissingArgDomain)
The three to fix first are the file name, the docblocks and the date call. The file name rule
exists because WordPress's own autoloading convention is class-{name}.php in lowercase;
renaming Admin_Page.php to class-wpsifero-notes-admin-page.php clears two of
the nine findings at once. The docblocks are usually one per class and one per method, using the
@param and @return tags. The date() call is the interesting one,
because the fix is not a different function call but a decision about what the value means: use
wp_date() for anything a person reads, and gmdate() only for a UTC instant
you are storing or comparing.
Two options make the tool usable on a codebase with history. phpcbf is the companion
binary that fixes everything marked with the little [x] in the report, which covers
whitespace, alignment and most docblock spacing automatically. And a phpcs.xml.dist with
a scoped <file> plus an incrementally tightened baseline is how you adopt the
standards on an existing plugin without a thousand-line diff that nobody reviews. The point of the
tool is the diff it blocks next month, not the report it prints today.
Take a plugin you have already written and run PHPCS over it with the ruleset above. Pick the five findings that appear most often and fix them.
Then answer four questions. Which of the findings are real bugs and which are formatting?
Which one would a translator have complained about? What does the plugin's
Text Domain header currently say, and does the string passed to every
__() call in the plugin match it exactly? And if you ship no .mo files,
what does removing load_plugin_textdomain() change on a site that has the plugin's
language pack installed?
Show the solution and the reasoning
The four questions have four different kinds of answer, and separating them is the point of the lab.
Real bugs against formatting. Most PHPCS findings are formatting: alignment,
spacing inside parentheses, the order of array keys. They are worth fixing because
phpcbf fixes them for free and because they make the diff noisy if you leave them.
A minority are behaviour. WordPress.DateTime.RestrictedFunctions.date_date is
behaviour: date() formats in the PHP timezone, which on a correctly configured site
is usually UTC while WordPress renders in the site timezone, so a stored "last saved" time can be
hours out. WordPress.Security.EscapeOutput is behaviour, and the most serious class of
finding the tool reports. WordPress.WP.I18n.MissingArgDomain is behaviour too, in the
sense that the string it flags is not translatable at all.
The translator's complaint. A missing or wrong text domain produces the exact
symptom in the opening paragraph: the string is not in the translation interface, so no translator
ever sees it. PHPCS reports it as MissingArgDomain when the domain is absent. When the
domain is present but wrong, PHPCS only catches it if you configured the allowed domain in the
ruleset, which is why that text_domain property is worth the four lines it costs. The
second complaint, one step later, is a %s with no translator comment: the translator
sees the string and has to guess what fills the hole.
The header and the calls. The Text Domain header is what
WordPress.org reads. It has to equal the directory slug exactly, and every call has to pass the same
literal. A quick audit is a grep for the domain in the plugin: any call with a different string, any
call with a variable, and any call with two arguments instead of three is a string that will never
be translated. The header itself is not load-bearing for translation at runtime; the literal in the
call is.
Removing load_plugin_textdomain. On a site with the language pack installed, the
behaviour does not change. Since 4.6 the loader checks wp-content/languages/plugins/,
which is where language packs land, and since 6.7 loading is deferred until the first string from
the domain is requested. Removing the call only changes behaviour for a plugin that ships its own
.mo files in its directory, or for a site that has them in an unusual location. If
either applies, keep the call and point it at the right path; if neither applies, the call is dead
code that makes the next reader think translations are handled manually.
// The compliant version of the flagged function, after the three common fixes.
class WPSifero_Notes_Admin_Page {
/**
* Prints the heading and the time of the last save.
*
* @param int $last_saved Unix timestamp of the last save.
* @return void
*/
public function render( int $last_saved ): void {
$last = wp_date( 'Y-m-d H:i:s', $last_saved );
echo '<h1>' . esc_html__( 'Notes', 'wpsifero-notes' ) . '</h1>';
echo '<p>' . esc_html(
sprintf(
/* translators: %s: date and time of the last save. */
__( 'Last saved: %s', 'wpsifero-notes' ),
$last
)
) . '</p>';
}
}
With that in place and the file renamed to
class-wpsifero-notes-admin-page.php, the same run reports
0 errors, 0 warnings. The value of getting there is not the clean report. It is that
every future change is checked against the rules you agreed to, and the four lines of ruleset
configuration that name your text domain are what make the i18n rules specific to your plugin.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.