One day I had a problem. On this website, I use the Avada theme for WordPress. As you can see, you can leave a comment under every blog post. You can enter a comment text, a name, an email and — normally  — a website address. Unfortunately, that address is clickable by default when the comment is shown. As I didn’t want to attract spam comments, I disabled the input of a website URL with CSS. Everything’s fine.

Or so it seemed. With the advent of the GDPR regulation in the European Union (read all about it here), there was a new problem: the input of name and email in a WordPress comment section is not optional, but required by default. According to GDPR rules, you are not allowed to force users to pass on personal data if they don’t want to. But no problem here, you can make the input of name and email optional by unchecking the setting Comment author must fill out name and email in the WordPress Settings in the section Discussion, subsection Other comment settings. After that, a user can post comments without entering a name or email.

That is, he could do that. The placeholder labels in the input fields, even after unchecking that setting,  still read Name (required) and Email (required). So I don’t have a technical problem, but a legal one. Even though I don’t require users to input name and email, it looks as if I would do that, violating the GDPR rules.

The text of these labels are controlled by the Avada WordPress theme and cannot be changed easily. My first approach was to create changed language files for the Avada theme. As you already might know, these language files — which are called PO files after their file extension (or MO files, when they are compiled) — are used for internationalization and translation in WordPress. As this site is bilingual, I had to create two files: a German translation file and, although normally not needed, an English translation file, which leaves everything as it is, except the two labels I wanted to change. So, in the English translation file, the two labels Name (required) and Email (required) were “translated” to Name and Email respectively.

The problem is, of course, that a change to the translation files will revert every time after a version update of the Avada theme, or at least after an update of the Avada language files, requiring to reapply that patch. It is also not possible to override translations in a child theme: you can only create new translations, not overwrite existing ones (that is: the function load_textdomain uses the function merge_with of the MO object where existing keys take precedence over new keys).

The solution here is to use a filter function to change strings, namely the gettext filter and its derivatives. To clearly state the limitations of this method from the beginning: you can only change strings which are intended by the theme, plugin or widget to be translated. Additionally, it would be best to confine the number of string replacements to a reasonably small number. Also, this filter function will be called for every translatable string in your WordPress installation: so it’s best to keep it small and fast.

But let’s start. The code we develop here will have to be appended to the file functions.php in your theme file directory. Or better: it should be part of the file functions.php in your child theme, to be update proof. You don’t have a child theme? Create one. Or even better: create a separate PHP file in your child themes folder and include it in the file functions.php.

So to change the placeholder in  the comment input fields of the Avada theme, the gettext filter might be used like this:

add_filter( 'gettext', 'my_gettext', 20, 3 );
function my_gettext( $msgstr, $msgid, $domain ) {
  if ($msgid == 'Name (required)' && $domain == 'Avada') return 'Name (optional)';
  if ($msgid == 'Email (required)' && $domain == 'Avada') return 'Email (optional, not shown)';
  return $msgstr;
}

We register a filter callback, which receives three parameters: $msgstr contains the default text as it would be shown without the filter function, $msgid contains the msgid of the text to be translated and $domain is a key to confine the translation to a specific named subset. This way, the same msgid can be used multiple times by different themes, plugins or widgets. In our case, the Avada WordPress theme registers its language files with the domain name “Avada“. The default WordPress translations use the domain name “default“. If we don’t want to change the string, we just return the default translation.

To use a more generic example, we might change the placeholder labels of the default WordPress comment input fields, used by e.g. the new standard theme Twenty Nineteen:

add_filter( 'gettext', 'my_gettext', 20, 3 );
function my_gettext( $msgstr, $msgid, $domain ) {
  if ($msgid == 'Name' && $domain == 'default') return 'Name (optional)';
  if ($msgid == 'Email' && $domain == 'default') return 'Email (optional, not shown)';
  return $msgstr;
}

But it’s a bit more complex, a translation can also be defined within a context. That allows for different translations in the same domain when a word in the source language might have different translations in the target language depending on the context. For example, the word “comment” can be a noun or a verb, but the translation in many languages will be different. So in the standard WordPress language file, the msgid for the word “comment” has the context “noun“.

There is another filter function to allow contexts, which is named gettext_with_context with an additional context parameter named $msgctxt. So if we would want to change the word “comment” to, let’s say, “opinion“, we can do it like this:

add_filter( 'gettext_with_context', 'my_gettext_with_context', 20, 4 );
function my_gettext( $msgstr, $msgid, $msgctxt, $domain ) {
  if ($msgid == 'Comment' && $msgctxt == 'noun' && $domain == 'default') return 'Opinion';
  return $msgstr;
}

To make it all more practical, we can introduce an array of translations, in this case, an associative array of arrays containing msgid, msgctxt, and domain in that order, with msgstr as the key. As gettext_with_context is more generic, we call it from our gettext callback:

$my_translation_table['Name'] = ['Name (optional)', '', 'default'];
$my_translation_table['Email'] = ['Email (optional, not shown)', '', 'default'];
$my_translation_table['Comment'] = ['Opinion', 'noun', 'default'];

add_filter( 'gettext_with_context', 'my_gettext_with_context', 20, 4 );
function my_gettext_with_context( $msgstr, $msgid, $msgctxt, $domain ) {
  global $my_translation_table;

  if (isset($my_translation_table[$msgid])) {
    if ($my_translation_table[$msgid][1] == $msgctxt) {
      if ($my_translation_table[$msgid][2] == $domain) {
        return $my_translation_table[$msgid][0];
      }
    }
  }

  return $msgstr;
}

add_filter('gettext', 'my_gettext', 20, 3);
function my_gettext($msgstr, $msgid, $domain) {
  return my_gettext_with_context($msgstr, $msgid, '', $domain);
}

There is yet another type of gettext filter callbacks. One can define a translation for a string in its singular or plural variant, e.g. “1 month” or “2 months“. The callbacks are named ngettext and ngettext_with_context. In our example code, we will add them here in a somewhat sloppy way, so a word of warning might be appropriate: keep in mind that a translation might not only depend on the cases one or more than one, like e.g. 21st, 22nd, and 23rd. So the code shown here is oversimplified, though it will work most cases.

add_filter( 'ngettext', 'my_ngettext', 20, 5 );
function my_ngettext($msgstr, $single, $plural, $number, $domain)
{
  if ($number == 1) {
    return my_gettext_with_context($msgstr, $single, '', $domain);
  } else {
    return my_gettext_with_context($msgstr, $plural, '', $domain);
  }
}

add_filter('ngettext_with_context', 'my_ngettext_with_context', 20, 6);
function my_ngettext_with_context($msgstr, $single, $plural, $number, $msgctxt, $domain)
{
  if ($number == 1) {
    return my_gettext_with_context($msgstr, $single, $msgctxt, $domain);
  } else {
    return my_gettext_with_context($msgstr, $plural, $msgctxt, $domain);
  }
}

So how do we know the id, the context and the domain of a text string or label we want to change? We have to look into the language files or in the code. The language files in the WordPress installation are located under /wp-content/languages, /wp-content/languages/plugins, and /wp-content/languages/themes. Open the corresponding PO files and search for the string you want to change. If you are running a WordPress site in the default US English configuration, there will be no default language file. You can download additional WordPress language files like en_GB or en_CA from this location and search them with your text editor of choice to find the text you are looking for. In every other case, the supplier of your theme or plugin should provide you with an appropriate language file.

You might also want to check the source code of the theme or plugin in question in  /wp-content/themes or /wp-content/plugins. The best idea might be to use a text editor which offers a “Find in Files” function. When reading the code, it might also be helpful to know the PHP functions, that WordPress uses for localization. Below is a list of all relevant functions with information about their use of context or singular/plural variants. Just click on the function name to see its definition.

FunctionReturn / DisplayEscape functionContextSingle / Plural
__()Returns translationnono
_e()Displays translationnono
esc_attr__()Returns translationesc_attr()nono
esc_html__()Returns translationesc_html()nono
esc_attr_e()Displays translationesc_attr()nono
esc_html_e()Displays translationesc_html()nono
_n()Returns translationnoyes
_x()Returns translationyesno
_ex()Displays translationyesno
esc_attr_x()Returns translationesc_attr()yesno
esc_html_x()Returns translationesc_html()yesno
_nx()Returns translationyesyes

To round it all up, we can extend the code to use two different languages. To achieve this, we can introduce two different translation arrays and wrap them into another associative array with the name of the locale as the key. The code of the gettext_with_context callback has to be slightly extended:

$my_translation_table_en_US['Name'] = ['Name (optional)', '', 'default'];
$my_translation_table_en_US['Email'] = ['Email (optional, not shown)', '', 'default'];
$my_translation_table_en_US['Comment'] = ['Opinion', 'noun', 'default'];

$my_translation_table_de_DE['Name'] = ['Name (Optional)', '', 'default'];
$my_translation_table_de_DE['Email'] = ['Email (Optional, wird nicht angezeigt)', '', 'default'];
$my_translation_table_de_DE['Comment'] = ['Meinung', 'noun', 'default'];

$my_translation_table['en_US'] = $my_translation_table_en_US;
$my_translation_table['de_DE'] = $my_translation_table_de_DE;

add_filter( 'gettext_with_context', 'my_gettext_with_context', 20, 4 );
function my_gettext_with_context( $msgstr, $msgid, $msgctxt, $domain )
{
  global $my_translation_table;

  $locale = apply_filters('theme_locale', determine_locale());

  if (isset($my_translation_table[$locale][$msgid])) {
    if ($my_translation_table[$locale][$msgid][1] == $msgctxt) {
      if ($my_translation_table[$locale][$msgid][2] == $domain) {
        return $my_translation_table[$locale][$msgid][0];
      }
    }
  }

  return $msgstr;
}

You can view and download the code at https://github.com/d3v-one/wp-gettext-example.