Skip to content

Multilingual Setup

This guide covers the Polylang Pro integration, language configuration, hreflang tags, AI-powered translation, and language-aware REST API queries.

Language structure

The site launches in two languages with English as the default:

Language Locale URL structure Flag
English (default) en_US Root path (/) us
Deutsch de_DE Subdirectory (/de/) de

The default language (English) uses hide_default: true, meaning English URLs have no language prefix. German content lives under /de/. This is configured programmatically -- not through the Polylang admin UI.

Code-defined configuration

All Polylang settings are managed in web/app/mu-plugins/polylang-customizations.php and synced to the database on every admin load. This means the configuration is version-controlled and consistent across environments.

Language creation

The tinnitus_polylang_ensure_languages() function runs on the pll_init hook and creates languages if they do not exist:

$desired = [
    [
        'name'       => 'English',
        'slug'       => 'en',
        'locale'     => 'en_US',
        'rtl'        => false,
        'term_group' => 0,
        'flag'       => 'us',
    ],
    [
        'name'       => 'Deutsch',
        'slug'       => 'de',
        'locale'     => 'de_DE',
        'rtl'        => false,
        'term_group' => 1,
        'flag'       => 'de',
    ],
];

Options sync

The tinnitus_polylang_sync_options() function enforces these Polylang options:

$desired = [
    'default_lang'  => 'en',
    'force_lang'    => 1,           // Subdirectory URL structure
    'hide_default'  => true,        // EN at root, DE at /de/
    'rewrite'       => true,        // Remove /language/ from URLs
    'browser'       => false,       // Don't auto-detect browser language
    'redirect_lang' => false,       // Don't redirect based on browser language
    'post_types'    => ['article', 'research', 'landing'],
    'taxonomies'    => ['cornerstone', 'tinnitus_type', 'tinnitus_stage', 'treatment_modality', 'audience'],
];

Adding a new translatable post type or taxonomy

When you create a new CPT or taxonomy that should be multilingual, add it to the post_types or taxonomies array in this function. The change deploys with the code -- no manual Polylang admin configuration needed.

Orphaned content assignment

On every admin load, tinnitus_polylang_assign_orphaned_content() checks all posts and taxonomy terms for a missing language assignment and assigns them to the default language (English). This handles post-migration scenarios and content created before Polylang was active.

Hreflang tags

Polylang generates <link rel="alternate" hreflang="..."> tags automatically. However, it omits the x-default tag when hide_default is enabled. The project adds it back via a filter:

function tinnitus_add_xdefault_hreflang(array $hreflangs): array
{
    if (isset($hreflangs['en']) && ! isset($hreflangs['x-default'])) {
        $hreflangs['x-default'] = $hreflangs['en'];
    }

    return $hreflangs;
}
add_filter('pll_rel_hreflang_attributes', 'tinnitus_add_xdefault_hreflang');

This produces correct hreflang output for SEO:

<link rel="alternate" hreflang="en" href="https://naluma.app/article-slug/" />
<link rel="alternate" hreflang="de" href="https://naluma.app/de/article-slug/" />
<link rel="alternate" hreflang="x-default" href="https://naluma.app/article-slug/" />

Localized date formats

WordPress stores a single date_format option. The project overrides it per language via the option_date_format filter so dates display in the correct locale format:

Language Format Example
English F j, Y March 26, 2026
German j. F Y 26. Marz 2026

String translations

The theme uses esc_html__('...', 'tinnitus') for all translatable strings. Polylang stores string translations in the database under the pll_string text domain. A bridge function copies the Polylang MO data into the tinnitus domain so both resolve:

function tinnitus_polylang_load_string_translations(): void
{
    if (isset($GLOBALS['l10n']['pll_string']) && $GLOBALS['l10n']['pll_string'] instanceof PLL_MO) {
        $GLOBALS['l10n']['tinnitus'] = &$GLOBALS['l10n']['pll_string'];
    }
}
add_action('pll_language_defined', 'tinnitus_polylang_load_string_translations', 10);

This means you manage string translations through Polylang's "Strings translations" admin page, and they automatically apply to all __('...', 'tinnitus') calls in theme code.

AI translation plugin

The tinnitus-ai-translate mu-plugin provides automated translation via OpenAI or Anthropic (Claude) APIs. It lives at web/app/mu-plugins/tinnitus-ai-translate/.

Capabilities

  • Translates post content (title, body, excerpt) including block markup
  • Translates ACF field values (summary, FAQ, sources)
  • Translates taxonomy terms
  • Translates Polylang registered strings
  • Automatic theme string discovery -- scans PHP files for __() and esc_html__() calls and registers them with Polylang

Prerequisites

  • Polylang Pro must be active (the plugin checks for POLYLANG_VERSION on load)
  • Action Scheduler must be available for auto-translation on publish (provided by Rank Math or WooCommerce)
  • An API key for OpenAI or Anthropic must be configured in the plugin settings

Admin interface

  • Settings page: WP Admin menu item for configuring the AI provider and API key
  • Metabox: Each translatable post shows a translation metabox with a button to trigger AI translation
  • Auto-translation: When a post is published, the plugin schedules an Action Scheduler job to translate it automatically

Action Scheduler dependency

If Action Scheduler is not available, the plugin displays an admin notice and disables auto-translation on publish. Manual translation via the metabox still works.

Language-aware REST API

The custom REST API at /tinnitus/v1/ supports a lang parameter for filtering content by language. This is consumed by the Flutter app and the marketing automation pipeline.

Query parameter

Pass lang=en or lang=de to filter results:

GET /wp-json/tinnitus/v1/articles?lang=de
GET /wp-json/tinnitus/v1/articles/42

The lang parameter is declared with validation in the base controller's get_collection_params():

'lang' => [
    'type'              => 'string',
    'enum'              => ['en', 'de'],
    'sanitize_callback' => 'sanitize_text_field',
    'validate_callback' => 'rest_validate_request_arg',
    'description'       => 'Filter by language (requires Polylang).',
],

Response fields

When Polylang is active, each item in the REST response includes language metadata:

{
    "id": 42,
    "title": "Understanding Tinnitus",
    "lang": "en",
    "translations": {
        "en": 42,
        "de": 87
    }
}

The translations object maps language slugs to post IDs, allowing the client to fetch the translated version directly.

Query internals

The language filter is applied in build_query_args() on the base controller. When Polylang is active, passing 'lang' => 'de' to WP_Query triggers Polylang's query filter, which restricts results to posts assigned to that language:

$lang = $request->get_param('lang');
if (is_string($lang) && '' !== $lang) {
    $args['lang'] = $lang;
}

Adding a new language

To add a third language in the future:

  1. Add the language definition to the $desired array in tinnitus_polylang_ensure_languages()
  2. Add the language slug to the lang enum in the REST API controller (get_collection_params())
  3. Add a date format entry to tinnitus_localize_date_format() if the locale has different date conventions
  4. Deploy the code -- the language is created automatically on the next admin page load
  5. Translate existing content via the AI translation plugin or manually through Polylang's translation workflow