Have you ever worked on localization with other web frameworks? If so, you might be familiar with the idea of using keys to identify translation strings. These keys are how your translation system finds the localized text to use inside your application.

If you’ve been doing localization with WordPress, you know that it doesn’t work that way. With WordPress, you use the original (usually English) content string as your translation key. This can be convenient because you always know what the original string was.

That said, it’s still possible to do WordPress localization using keys instead content strings. This can be useful if you come from these different programming backgrounds. You might like to keep working the way you’re used to. Let’s look at how you can do that!

Why would use keys for translations?

So the first question that you might be wondering is why even bother doing this? Why would you want to use keys for translations in the first place? Well, there are a few reasons to do it.

The main reason why it’s good to use keys for translations is pretty straightforward. Let’s say that you’re using the original English strings for your translations. Well, each time that you make a change to them, you invalidate all its translations.

Now, there are times when you want that to invalidate all the translation. But it shouldn’t happen when you make a mistake or want to clarify the original sentence. The translation might already be good enough.

The other reason (and that one is more subjective) is that using a key shows the intent of a translation. Having an original string that says “Submit” doesn’t show a lot of intent. We don’t really know what the context of the “Submit” string is.

This isn’t the case if you use a key that says “contact_form.submit_button”. The “contact_form.submit_button” key conveys a lot more meaning than the “Submit” string. At a glance, you know where you’ll use this translation string and what it’s for.

A quick look at gettext

Before we look at how we can use keys for translation, let’s do a quick review of gettext. So what is gettext? Well, it’s a popular internationalization and translation system that WordPress (and a lot of other applications and frameworks) uses to handle localization.

WordPress has a few functions that it offers as wrappers around gettext. The two most used ones are __ and _e . Both functions are quite similar, the only difference is that __ returns the translated string while _e echoes it.

If you’ve never used gettext, you should know that it’s not the most intuitive piece of software to use. At its core, gettext relies on three different file types: .pot , .po and .mo . .pot . In general, people tend to use an editor like poedit to interact with them.

gettext file types

In practice, we can’t do anything with the .mo files. They’re just the compiled version of the matching .po file. If you open them in a file editor, it just looks like gibberish.

This leaves us with two file types: .po and pot . These two file types work pretty much the same. It’s just what you use them for that’s different.

.po files are the files that you use to make your translations. They contain a succession text blocks. Each text blocks is two lines. The first starts with msgid and the other with msgstr .

msgid is the original untranslated string. With WordPress, that’ll be the English string. The goal of our article is to replace this English string with a translation key instead.

msgstr is the translated version of the msgid string above it. This brings us to the difference between a .po file and a .pot file. A .pot file is a template file used to generate the initial .po file used to translate a language.

That said, both files are almost identical. While a .po will contain msgstr with the translated version of the msgid string, .pot will just have msgstr left empty. That’s really the only difference between both files.

How to generate .pot file with WordPress

There are a few ways to generate a .pot file with WordPress. You can use gettext tools like poedit. There’s also JavaScript task runners like Grunt or Gulp. Or, if you’d rather not use either, the WordPress plugin repository can create one for you.

These tools will go through your code and look for translation strings. They do that by scanning for the specific WordPress gettext functions. (If you do your translations some other way, they won’t find them.) They’ll also only look for translation strings that belong to the text domain of your plugin.

These translation string are then all added into the .pot file as msgid entries. They’ll leave the msgstr entries blank since that’s what distinguishes a .pot file from a .po file. They also add a comment saying where they found these translation strings in your code.

Loading your translations into WordPress

With our generated .pot file, we can now create translations for our plugin. And let’s say that the plugin uses the myplugin domain for translations. If you want to translate your plugin in Spanish, you’ll create a myplugin-es_ES.po file from our myplugin.pot file.

Your gettext editor will also create a compiled myplugin-es_ES.mo file as you translate your myplugin-es_ES.po file. That said, WordPress still has no idea where to find your compiled translation files. You need to tell it where to find them.

The way to tell WordPress where to find your plugin translations is by using the load_plugin_textdomain function. (There’s also a load theme textdomain function for themes.) The function has three parameters: domain , deprecated and plugin_rel_path .

deprecated as the name suggests is a parameter that isn’t used anymore. This leaves us with only domain and plugin_rel_path . domain is the text domain of our plugin and it’s mandatory.

plugin_rel_path is an optional path where WordPress can find the .mo files for your plugin. As the name suggests, you’re supposed to pass it a relative path. That path should be relative to the WP_PLUGIN_DIR constant which contains the path to the plugin folder.

So what happens if you don’t pass a plugin_rel_path argument to the load_plugin_textdomain function? Well, WordPress is going to look for the .mo files for your plugin in the default location. That default location is the root of the plugin directory. Or to be more specific, the location that WP_PLUGIN_DIR points to.

Obstacle to using placeholders with WordPress

Alright, so that was a good review of gettext and its use in WordPress! Now, that we have that covered, we can move on to using placeholders with WordPress. There’s really only one major obstacle that prevents us from achieving that goal.

What’s that obstacle? It’s that, since we’re no longer using English for our translation string, we no longer have a default language. This has multiple implications.

We need at least one translation

First, it means that, if we haven’t translated a plugin in another language, someone won’t see English text. No, they’re going to see our placeholders instead. And that’s not a good at all.

To fix that, we need to translate our plugin at least once. For most of us, that’ll mean translating our placeholders in English. But it can be in another language! (Like WordPress translation strings don’t have to be in English at first either.)

We also need a default translation

This need for at least one translation highlights another obstacle with using placeholders. Unlike translation strings, placeholders need a default translation language. Otherwise, someone will see placeholders when they use your plugin in a language that there’s no translation for.

Again, we don’t want someone to see the placeholders that we use in our plugin. So this isn’t something that we want to happen. The good news is that it’s quite easy to set a default language for our plugin!

Overcoming these obstacles

These obstacles might make it seem like using placeholders isn’t an easy thing to do. But that’s not the case at all! In fact, we already hinted at the necessary steps earlier in the article.

An example plugin

But first, we should have a small sample plugin that we can use as an example. It won’t be a very big plugin to keep things simple. But it’ll let us at least see how we can do this in practice!

// myplugin/index.php /* Plugin Name: My Plugin Text Domain: myplugin Author: Carl Alexander Author URI: https://carlalexander.ca License: GPL2 */ /** * Add our plugin's admin page */ function myplugin_add_admin_page() { add_options_page(__('admin.page_title', 'myplugin'), __('admin.menu_title', 'myplugin'), 'install_plugins', 'myplugin', 'myplugin_render_admin_page'); } add_action('admin_menu', 'myplugin_add_admin_page'); /** * Render our plugin's admin page. */ function myplugin_render_admin_page() { ?> <h1><?php _e('admin.page_title', 'myplugin'); ?></h1> <p><?php _e('admin.help_text', 'myplugin'); ?></p> <?php } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 // myplugin/index.php /* Plugin Name: My Plugin Text Domain: myplugin Author: Carl Alexander Author URI: https://carlalexander.ca License: GPL2 */ /** * Add our plugin's admin page */ function myplugin_add_admin_page ( ) { add_options_page ( __ ( 'admin.page_title' , 'myplugin' ) , __ ( 'admin.menu_title' , 'myplugin' ) , 'install_plugins' , 'myplugin' , 'myplugin_render_admin_page' ) ; } add_action ( 'admin_menu' , 'myplugin_add_admin_page' ) ; /** * Render our plugin's admin page. */ function myplugin_render_admin_page ( ) { ?> < h1 > <?php _e ( 'admin.page_title' , 'myplugin' ) ; ?> < / h1 > < p > <?php _e ( 'admin.help_text' , 'myplugin' ) ; ?> < / p > <?php }

As you can see, the plugin is quite simple! We’re just adding an options page using add_options_page . And we have a second function called myplugin_render_admin_page which renders the admin page HTML.

Even, if this is quite simple codewise, this a good example to demonstrate the use of placeholders. Registering and rendering an admin page requires a lot of text that developers usually need to translate. Even if this example is small, it already has three placeholders that we’ll need to translate.

Creating our translation template

Now, the first step is always going to be to create a default translation for your plugin. To do that, you’ll need to generate a .pot file for it. Like we mentioned earlier, there are a few ways for you to do that. Since our plugin is so small, we’re just going to create our .pot file by hand. We’ll name it myplugin.pot .

msgid "" msgstr "" "Project-Id-Version: My Plugin

" "Report-Msgid-Bugs-To:

" "MIME-Version: 1.0

" "Content-Type: text/plain; charset=UTF-8

" "Content-Transfer-Encoding: 8bit

" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE

" "Language-Team: Carl Alexander

" "Plural-Forms: nplurals=2; plural=(n != 1);

" msgid "admin.page_title" msgstr "" msgid "admin.menu_title" msgstr "" msgid "admin.help_text" msgstr "" 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 msgid "" msgstr "" "Project-Id-Version: My Plugin

" "Report-Msgid-Bugs-To:

" "MIME-Version: 1.0

" "Content-Type: text/plain; charset=UTF-8

" "Content-Transfer-Encoding: 8bit

" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE

" "Language-Team: Carl Alexander

" "Plural-Forms: nplurals=2; plural=(n != 1);

" msgid "admin.page_title" msgstr "" msgid "admin.menu_title" msgstr "" msgid "admin.help_text" msgstr ""

Ok, there’s a good chance that you didn’t expect our myplugin.pot file to have this much text. But it’s just the required header section that’s quite big. You can see that our translations only take a few lines.

As you can see, each translation in our .pot file follows the format that we described earlier. The first line is msgid followed by our placeholder. The second line is msgstr with an empty string.

Using our myplugin.pot file, we can create the .po and .mo files for our default language translation. You can do this with the tool of your choice. But you need to use one because, while we can create our .po file by hand, you can’t do it for the .mo .

Creating a default translation

For this example, we’re going to use American English as our default language. The IETF language tag for it is en_US . This means that our .po and .mo will respectively be myplugin-en_US.po and myplugin-en_US.mo .

msgid "" msgstr "" "Project-Id-Version: My Plugin

" "Report-Msgid-Bugs-To:

" "MIME-Version: 1.0

" "Content-Type: text/plain; charset=UTF-8

" "Content-Transfer-Encoding: 8bit

" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE

" "Language-Team: Carl Alexander

" "Language: en_US

" "Plural-Forms: nplurals=2; plural=(n != 1);

" msgid "admin.page_title" msgstr "My Plugin Settings" msgid "admin.menu_title" msgstr "My Plugin" msgid "admin.help_text" msgstr "You can find the available plugin settings below." 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 msgid "" msgstr "" "Project-Id-Version: My Plugin

" "Report-Msgid-Bugs-To:

" "MIME-Version: 1.0

" "Content-Type: text/plain; charset=UTF-8

" "Content-Transfer-Encoding: 8bit

" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE

" "Language-Team: Carl Alexander

" "Language: en_US

" "Plural-Forms: nplurals=2; plural=(n != 1);

" msgid "admin.page_title" msgstr "My Plugin Settings" msgid "admin.menu_title" msgstr "My Plugin" msgid "admin.help_text" msgstr "You can find the available plugin settings below."

Here’s a look at our myplugin-en_US.po file. It’s very similar to our previous myplugin.pot file. The only difference is that we set a language in the header and that our msgstr entries aren’t empty anymore.

The tool that you used to edit your myplugin-en_US.po file should have also created a myplugin-en_US.mo file. This is the compiled translation file for our American English translation. This is the file that gettext and WordPress are going to use.

Loading our translations

Now that we have our default translation, we need to add code so that WordPress loads it. As we saw earlier, we do this using the load_plugin_textdomain function. In general, most plugins use the plugins_loaded Plugin API hook to load it.

/** * Register the plugin's translations files with WordPress. */ function myplugin_load_translation() { load_plugin_textdomain('myplugin', false, basename(dirname(__FILE__)) . '/translations'); } add_action('plugins_loaded', 'myplugin_load_translation'); 1 2 3 4 5 6 7 /** * Register the plugin's translations files with WordPress. */ function myplugin_load_translation ( ) { load_plugin_textdomain ( 'myplugin' , false , basename ( dirname ( __FILE__ ) ) . '/translations' ) ; } add_action ( 'plugins_loaded' , 'myplugin_load_translation' ) ;

Above is the code that we added to our plugin to load our translations. We have a new myplugin_load_translation function which runs on the plugins_loaded hook. The function itself calls the load_plugin_textdomain function with three arguments.

We have myplugin which is our plugin’s domain. Next, we have false which is the deprecated parameter. The last argument is the one that’s worth discussing a bit more.

This is the argument for the plugin_rel_path parameter. It tells WordPress where to find our translation files relative to the WP_PLUGIN_DIR constant. So how do we do that?

First, we need to know the name of our plugin directory in the /plugins directory. We do that by first determining the full path to the index.php file of our plugin. You can do this by using the dirname and passing it the __FILE__ magic constant. Or, if you’re using PHP 5.3 or higher, you can use the __DIR__ magic constant instead of this.

We then pass that full path to the basename function which gets us our plugin directory name. That’s a lot of work to get a directory name! But it’s necessary since anyone can rename our plugin directory at any time. We can’t assume it’ll always be same.

This brings us to the last step. We use this directory name and append /translations to it. This is the directory where we’ll put our plugin translation files (Feel free to name it something else if you prefer!)

Setting a default translation

The last piece of the puzzle is to have WordPress always load our default translation. This isn’t that hard to do because WordPress has a convenient filter that we can use to do it! It’s the plugin_locale filter.

/** * Enforces the use of the "en_US" locale for translations. * * This is necessary since we're using placeholder values for text instead of English text. * * @param string $locale * @param string $domain * * @return string */ function myplugin_enforce_locale($locale, $domain) { if ('myplugin' == $domain) { $locale = 'en_US'; } return $locale; } add_filter('plugin_locale', 'myplugin_enforce_locale', 10, 2); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /** * Enforces the use of the "en_US" locale for translations. * * This is necessary since we're using placeholder values for text instead of English text. * * @param string $locale * @param string $domain * * @return string */ function myplugin_enforce_locale ( $locale , $domain ) { if ( 'myplugin' == $domain ) { $locale = 'en_US' ; } return $locale ; } add_filter ( 'plugin_locale' , 'myplugin_enforce_locale' , 10 , 2 ) ;

Here’s how we can enforce our default translation using the plugin_locale filter. We created the myplugin_enforce_locale function which added to the filter using the add_filter function. We passed it two extra arguments because the myplugin_enforce_locale function has two parameters.

These two parameters are locale and domain . locale is the locale that WordPress will use with the plugin. domain is the text domain that WordPress is loading.

We know the text domain of our plugin. It’s myplugin . That’s why our myplugin_enforce_locale function starts with a guard clause.

It checks that domain is equal to myplugin . If it’s equal, we know it’s loading our plugin’s locale. So we change the value of locale to the language tag for our default translation which is en_US . After that, the function returns the locale value that the guard clause might have modified.

And that’s it!

The goal of this article was to show that it’s possible to work with placeholders with WordPress. And at this point, you have all the code that you need to do that. The only thing left for you to do is to decide whether you want to use them or not!

Like we saw at the beginning of this article, there are advantages and disadvantages to both. So it’s more of a matter of personal preference than anything else. But whatever you pick, you should always try to translate your WordPress code. It’s the right thing to do!