docs(09): research phase domain

Phase 09: Internationalization
- WordPress gettext patterns documented
- WP-CLI and Loco Translate compatibility confirmed
- switch_to_locale() bug #39210 workaround identified
- Text domain mismatch detected (needs correction)
- Email locale forcing strategy researched

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 23:24:06 +09:00
parent 9aa1b9c107
commit 5ddc2a15a8

View File

@@ -0,0 +1,636 @@
# Phase 9: Internationalization - Research
**Researched:** 2026-02-06
**Domain:** WordPress i18n (gettext-based localization)
**Confidence:** HIGH
## Summary
WordPress internationalization uses the gettext system with well-established patterns. Plugins wrap user-facing strings in translation functions (`__()`, `_e()`, `esc_html__()`, etc.), load a text domain in the `init` hook, and ship POT/PO/MO files in a `languages/` directory.
**Critical findings for this phase:**
1. **Text domain mismatch detected:** Plugin header declares `umzugsliste` but folder is `Siegel-Umzugsliste`. WordPress best practice requires text domain to match the plugin slug (folder name). Need to change text domain to `siegel-umzugsliste` (lowercase with dashes).
2. **Email locale forcing:** WordPress provides `switch_to_locale()` and `restore_previous_locale()` for temporarily changing locale, but there's a known issue where plugin translations don't reload automatically. The workaround is using the `change_locale` hook to manually reload the text domain.
3. **Translating array data:** Furniture/room names in data arrays should use inline `__()` calls within the array definitions. Email generation needs to access German originals regardless of current locale.
4. **Loco Translate compatibility:** Ship an up-to-date POT file named exactly `siegel-umzugsliste.pot` in the `languages/` directory for Loco Translate auto-discovery.
**Primary recommendation:** Use WP-CLI `wp i18n make-pot` to generate POT file, wrap all user-facing strings in appropriate gettext functions, implement locale switching for email generation with the `change_locale` hook workaround, and ship both POT template and German PO/MO files for out-of-box German support.
## Standard Stack
The established libraries/tools for WordPress i18n:
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| WordPress gettext | Core API | String translation via `__()`, `_e()`, etc. | Built into WordPress core since 2.1 |
| WP-CLI i18n | WP-CLI package | POT file generation via `wp i18n make-pot` | Official WordPress tool, replaces manual xgettext |
| GNU gettext tools | System package | PO/MO compilation via `msgfmt`, `msginit` | Industry standard for gettext workflows |
| Loco Translate | WordPress plugin | Translation management UI | De facto standard for in-dashboard translation editing |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| `load_plugin_textdomain()` | Core function | Loads translation files | Called on `init` hook to register plugin text domain |
| `switch_to_locale()` | Core function (4.7+) | Temporarily changes locale | For sending emails in specific language regardless of site locale |
| `restore_previous_locale()` | Core function (4.7+) | Restores previous locale | After `switch_to_locale()` to return to original locale |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| WP-CLI | Manual xgettext | WP-CLI is WordPress-aware, handles PHP and JS, better string extraction |
| Loco Translate | WPML / Polylang | Loco is free and focused on translation editing, not multilingual content management |
| `load_plugin_textdomain()` | `load_textdomain()` | `load_plugin_textdomain()` is plugin-specific convenience wrapper with standard paths |
**Installation:**
```bash
# WP-CLI (for POT generation during development)
# Usually already installed in WordPress dev environments
wp cli version
# GNU gettext tools (for PO/MO compilation)
# macOS:
brew install gettext
# Debian/Ubuntu:
apt-get install gettext
# Loco Translate (for end-user translation management)
# Install via WordPress admin: Plugins > Add New > Search "Loco Translate"
```
## Architecture Patterns
### Recommended Project Structure
```
siegel-umzugsliste/
├── languages/ # Translation files directory
│ ├── siegel-umzugsliste.pot # Template (required for Loco Translate)
│ ├── siegel-umzugsliste-de_DE.po # German translations (source)
│ └── siegel-umzugsliste-de_DE.mo # German translations (compiled)
├── includes/
│ ├── class-*.php # All strings wrapped in gettext
│ └── ...
└── umzugsliste.php # Plugin header with Text Domain
```
### Pattern 1: Text Domain Loading
**What:** Register the plugin's text domain so WordPress knows where to find translation files
**When to use:** Always, in main plugin file on `init` hook
**Example:**
```php
// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/
/**
* Load plugin text domain
*/
function siegel_umzugsliste_load_textdomain() {
load_plugin_textdomain(
'siegel-umzugsliste',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
add_action( 'init', 'siegel_umzugsliste_load_textdomain' );
```
**Note:** As of WordPress 4.6+, if plugin requires 4.6+, `load_plugin_textdomain()` is optional for wordpress.org-hosted plugins (translate.wordpress.org is prioritized). However, including it ensures local translation files work and maintains compatibility with Loco Translate.
### Pattern 2: Basic String Translation
**What:** Mark strings for translation using appropriate gettext functions
**When to use:** All user-facing strings in PHP code
**Example:**
```php
// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/
// Simple translation (returns string)
$label = __( 'Moving Date', 'siegel-umzugsliste' );
// Echo translation (outputs directly)
_e( 'Moving Date', 'siegel-umzugsliste' );
// Translation with HTML escaping (returns escaped string)
$safe_label = esc_html__( 'Moving Date', 'siegel-umzugsliste' );
// Translation with HTML escaping (echoes escaped string)
esc_html_e( 'Moving Date', 'siegel-umzugsliste' );
// Translation with attribute escaping (for use in HTML attributes)
echo '<input type="text" placeholder="' . esc_attr__( 'Enter your name', 'siegel-umzugsliste' ) . '">';
```
**Key rules:**
- Always pass text domain as second parameter
- Text domain must be a string literal (not variable or constant)
- Use escaped variants (`esc_html__()`, `esc_attr__()`) for security
### Pattern 3: Variables in Translatable Strings
**What:** Use placeholders with `sprintf()` or `printf()` for strings containing variables
**When to use:** Any translatable string that includes dynamic content
**Example:**
```php
// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/
// Correct approach with sprintf
printf(
/* translators: %s: Customer name */
esc_html__( 'Moving list for %s', 'siegel-umzugsliste' ),
esc_html( $customer_name )
);
// WRONG - variables inside translation string
// _e( "Moving list for $customer_name", 'siegel-umzugsliste' ); // Don't do this!
// Multiple placeholders with numbered arguments (for reordering in translations)
printf(
/* translators: 1: source address, 2: destination address */
esc_html__( 'Moving from %1$s to %2$s', 'siegel-umzugsliste' ),
esc_html( $source ),
esc_html( $destination )
);
```
**Why this matters:** Translators see the literal string during extraction. If variables are embedded, translations break because the runtime string differs from the extracted string.
### Pattern 4: Translating Data Array Strings
**What:** Wrap array values in `__()` calls so they're extracted to POT and translatable
**When to use:** Static data arrays like furniture items and room names
**Example:**
```php
// Based on research: https://codex.wordpress.org/I18n_for_WordPress_Developers
public static function get_rooms() {
return array(
'wohnzimmer' => __( 'Wohnzimmer', 'siegel-umzugsliste' ),
'schlafzimmer' => __( 'Schlafzimmer', 'siegel-umzugsliste' ),
'arbeitszimmer' => __( 'Arbeitszimmer', 'siegel-umzugsliste' ),
'bad' => __( 'Bad', 'siegel-umzugsliste' ),
'kueche_esszimmer' => __( 'Küche/Esszimmer', 'siegel-umzugsliste' ),
'kinderzimmer' => __( 'Kinderzimmer', 'siegel-umzugsliste' ),
'keller' => __( 'Keller/Speicher/Garage', 'siegel-umzugsliste' ),
);
}
// For nested arrays (furniture items)
array(
'name' => __( 'Sofa, Couch, je Sitz', 'siegel-umzugsliste' ),
'cbm' => 0.4,
'montage' => true
),
```
**Important:** For email generation that must always be in German, you'll need a separate mechanism to get original German strings (see Pattern 6).
### Pattern 5: Locale Switching for Email Generation
**What:** Temporarily switch to German locale when generating email, then restore
**When to use:** Email generation that must always be in German regardless of site locale
**Example:**
```php
// Source: https://developer.wordpress.org/reference/functions/switch_to_locale/
// Known issue: https://core.trac.wordpress.org/ticket/39210
public static function generate_email_in_german( $data ) {
// Switch to German
switch_to_locale( 'de_DE' );
// Generate email using translated strings
// All __() calls will now return German translations
$email_html = self::generate( $data );
// Restore previous locale
restore_previous_locale();
return $email_html;
}
```
**Critical caveat:** `switch_to_locale()` has a known bug where plugin translations don't automatically reload. See "Don't Hand-Roll" section for the required workaround.
### Pattern 6: Accessing Original Strings for Email
**What:** Maintain separate German string lookup to bypass translation system for email
**When to use:** When you need original German strings regardless of translation state
**Example:**
```php
// Alternative to locale switching if it proves problematic
/**
* Get untranslated German furniture data for email
* Returns furniture items with original German names
*/
public static function get_furniture_items_german( $room_key ) {
// Same structure as get_all_furniture_data() but without __() calls
$german_data = array(
'wohnzimmer' => array(
array( 'name' => 'Sofa, Couch, je Sitz', 'cbm' => 0.4, 'montage' => true ),
// ... rest of items with hardcoded German strings
),
);
return $german_data[ $room_key ] ?? array();
}
```
**Tradeoff:** Duplicates data but guarantees German email regardless of translation state. Decision: Use locale switching first, fall back to this if issues arise.
### Pattern 7: POT File Generation
**What:** Generate POT template file that contains all translatable strings
**When to use:** During development after adding/changing translatable strings, before release
**Example:**
```bash
# Source: https://developer.wordpress.org/cli/commands/i18n/make-pot/
# Navigate to plugin root
cd /path/to/wp-content/plugins/siegel-umzugsliste
# Generate POT file
wp i18n make-pot . languages/siegel-umzugsliste.pot --domain=siegel-umzugsliste
# Initialize German PO file from POT (first time only)
msginit --input=languages/siegel-umzugsliste.pot \
--locale=de_DE \
--output-file=languages/siegel-umzugsliste-de_DE.po
# After editing PO file, compile to MO
msgfmt languages/siegel-umzugsliste-de_DE.po \
-o languages/siegel-umzugsliste-de_DE.mo
```
### Anti-Patterns to Avoid
- **Variables in gettext calls:** Never use `__( "Text $variable", 'domain' )` - breaks translation extraction
- **Concatenating translations:** Don't split sentences across multiple `__()` calls - breaks grammar in other languages
- **Dynamic text domains:** Never use `__( 'Text', $variable_domain )` - must be string literal
- **Forgetting text domain:** Omitting the second parameter breaks translations
- **Wrong escaping context:** Using `esc_html__()` for attributes or vice versa - security risk
- **Text domain in variable:** `__( 'Text', DOMAIN_CONSTANT )` won't work - must be literal string
## Don't Hand-Roll
Problems that look simple but have existing solutions:
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| POT file extraction | Custom regex/parser to find strings | `wp i18n make-pot` | Handles PHP, JS, edge cases, comments, context, plurals. Regex misses nested calls, string concatenation, heredoc. |
| PO to MO compilation | Custom binary MO writer | `msgfmt` from gettext tools | MO format is complex binary with endianness, hash tables, offsets. Msgfmt is battle-tested. |
| Locale switching with plugin translations | Just use `switch_to_locale()` alone | Add `change_locale` hook to reload text domain | Core bug (#39210): plugin translations unload on locale switch. Hook workaround required. |
| Fallback to German for unsupported locales | Custom `gettext` filter to check locale | Language Fallback plugin OR custom `locale` filter | German fallback needs to apply before translation loading, not during gettext calls. Requires locale chain modification. |
| JavaScript translation | Custom JSON translation loader | `wp_localize_script()` for simple cases, `wp_set_script_translations()` for Gutenberg | WordPress has built-in mechanisms. Custom loaders miss WordPress hooks, filters, caching. |
| Translation file discovery | Custom file scanner | Loco Translate plugin | Handles .pot/.po/.mo scanning, fuzzy matching, MO compilation, editor UI. Complex directory structures. |
**Key insight:** WordPress i18n has sharp edges (locale switching bug, text domain literal requirement, MO binary format). The ecosystem has mature tools that handle these. Custom solutions inevitably rediscover the same bugs.
## Common Pitfalls
### Pitfall 1: Text Domain Doesn't Match Plugin Slug
**What goes wrong:** Translations don't load, WordPress.org translation system doesn't work, Loco Translate can't auto-discover
**Why it happens:** Developer chooses descriptive text domain without realizing it must match folder name
**How to avoid:** Text domain must match plugin folder name, use dashes (not underscores), lowercase only
**Warning signs:** `load_plugin_textdomain()` returns false, translations exist but don't apply, Loco Translate doesn't find the plugin
**Current issue:** This plugin has `Text Domain: umzugsliste` in header but folder is `Siegel-Umzugsliste`. Correct text domain should be `siegel-umzugsliste`.
### Pitfall 2: switch_to_locale() Doesn't Load Plugin Translations
**What goes wrong:** After calling `switch_to_locale('de_DE')`, plugin strings still show in original locale instead of German
**Why it happens:** WordPress core bug #39210 - `switch_to_locale()` unloads all text domains and reloads core translations, but plugin text domains aren't automatically reloaded
**How to avoid:** Hook into `change_locale` action and manually call `load_plugin_textdomain()` to reload plugin translations
**Warning signs:** Core WordPress strings change language but plugin strings don't, translations work on normal pages but not after `switch_to_locale()`
**Solution:**
```php
// Workaround for switch_to_locale() plugin translation bug
add_action( 'change_locale', function( $locale ) {
// Reload plugin text domain after locale change
unload_textdomain( 'siegel-umzugsliste' );
load_plugin_textdomain(
'siegel-umzugsliste',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
} );
```
### Pitfall 3: Variables Inside Translation Strings
**What goes wrong:** Translations don't work - string with variable content never matches extracted POT string
**Why it happens:** POT extraction sees literal code, but runtime sees different string after variable interpolation
**How to avoid:** Always use `sprintf()` or `printf()` with placeholders, never embed variables directly
**Warning signs:** Translation exists in PO file but doesn't apply, POT file contains partial string without variable content
**Example:**
```php
// WRONG - variable interpolation
$msg = __( "Hello $name", 'siegel-umzugsliste' );
// CORRECT - sprintf with placeholder
$msg = sprintf( __( 'Hello %s', 'siegel-umzugsliste' ), $name );
```
### Pitfall 4: Forgetting to Regenerate POT After Code Changes
**What goes wrong:** New strings don't appear in Loco Translate, translators can't translate new features, translations fall back to English
**Why it happens:** POT file is static - it's a snapshot of translatable strings at generation time
**How to avoid:** Regenerate POT file before each release, add to release checklist, consider adding to CI/CD
**Warning signs:** Recent code changes have translatable strings but they don't appear in translation editor
**Solution:** Always run `wp i18n make-pot` before committing translation-related changes
### Pitfall 5: Wrong Escaping Function for Context
**What goes wrong:** XSS vulnerabilities or broken HTML entity display
**Why it happens:** `esc_html__()` encodes quotes differently than `esc_attr__()`, using wrong one breaks output
**How to avoid:** Use `esc_html__()` for HTML content, `esc_attr__()` for HTML attributes, `esc_js__()` for JavaScript strings
**Warning signs:** Quote marks display as `&quot;` in visible text, or HTML attributes break with special characters
**Example:**
```php
// CORRECT - html escaping for content
echo '<p>' . esc_html__( 'Room: "Living Room"', 'siegel-umzugsliste' ) . '</p>';
// Output: <p>Room: "Living Room"</p>
// CORRECT - attribute escaping for attributes
echo '<input placeholder="' . esc_attr__( 'Enter name', 'siegel-umzugsliste' ) . '">';
// WRONG - html escaping in attribute breaks quotes
// echo '<input placeholder="' . esc_html__( 'Enter name', 'siegel-umzugsliste' ) . '">';
```
### Pitfall 6: Translation Arrays Not Extracted to POT
**What goes wrong:** Array-based strings (like furniture items) don't appear in POT file for translation
**Why it happens:** If arrays use plain strings without `__()` calls, WP-CLI doesn't recognize them as translatable
**How to avoid:** Wrap every translatable string in arrays with `__()`, even if it looks redundant
**Warning signs:** Form displays furniture items but they don't appear in Loco Translate
**Solution:**
```php
// WRONG - plain strings in array
return array(
'wohnzimmer' => 'Wohnzimmer',
'schlafzimmer' => 'Schlafzimmer',
);
// CORRECT - wrapped in __()
return array(
'wohnzimmer' => __( 'Wohnzimmer', 'siegel-umzugsliste' ),
'schlafzimmer' => __( 'Schlafzimmer', 'siegel-umzugsliste' ),
);
```
## Code Examples
Verified patterns from official sources:
### Loading Text Domain on Init Hook
```php
// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/
/**
* Load plugin textdomain
* Call this on 'init' hook or 'plugins_loaded'
*/
function siegel_umzugsliste_load_textdomain() {
load_plugin_textdomain(
'siegel-umzugsliste',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
add_action( 'init', 'siegel_umzugsliste_load_textdomain' );
```
### Email Generation with Locale Forcing
```php
// Source: https://developer.wordpress.org/reference/functions/switch_to_locale/
// Combined with workaround for https://core.trac.wordpress.org/ticket/39210
class Umzugsliste_Email_Generator {
/**
* Generate email in German regardless of site locale
*/
public static function generate( $data ) {
// Save current locale
$original_locale = get_locale();
// Switch to German
switch_to_locale( 'de_DE' );
// Generate email content using __() functions
// All translatable strings will now return German translations
$email_html = self::build_email_html( $data );
// Restore original locale
restore_previous_locale();
return $email_html;
}
/**
* Build email HTML using translatable strings
* Called after locale switch, so __() returns German
*/
private static function build_email_html( $data ) {
$html = '<h1>' . esc_html__( 'Moving List', 'siegel-umzugsliste' ) . '</h1>';
// Furniture data with translated names
$rooms = Umzugsliste_Furniture_Data::get_rooms();
foreach ( $rooms as $room_key => $room_label ) {
// $room_label is already translated via __() in get_rooms()
$html .= '<h2>' . esc_html( $room_label ) . '</h2>';
}
return $html;
}
}
// CRITICAL: Add this workaround for switch_to_locale() bug
add_action( 'change_locale', function( $locale ) {
// Plugin translations don't auto-reload on locale switch (WP core bug #39210)
// Manually unload and reload to ensure plugin strings translate correctly
unload_textdomain( 'siegel-umzugsliste' );
load_plugin_textdomain(
'siegel-umzugsliste',
false,
dirname( plugin_basename( UMZUGSLISTE_PLUGIN_DIR . 'umzugsliste.php' ) ) . '/languages'
);
}, 10, 1 );
```
### Translating Furniture Data Arrays
```php
// Based on: https://codex.wordpress.org/I18n_for_WordPress_Developers
class Umzugsliste_Furniture_Data {
/**
* Get room definitions with translatable labels
*/
public static function get_rooms() {
return array(
'wohnzimmer' => __( 'Wohnzimmer', 'siegel-umzugsliste' ),
'schlafzimmer' => __( 'Schlafzimmer', 'siegel-umzugsliste' ),
'arbeitszimmer' => __( 'Arbeitszimmer', 'siegel-umzugsliste' ),
'bad' => __( 'Bad', 'siegel-umzugsliste' ),
'kueche_esszimmer' => __( 'Küche/Esszimmer', 'siegel-umzugsliste' ),
'kinderzimmer' => __( 'Kinderzimmer', 'siegel-umzugsliste' ),
'keller' => __( 'Keller/Speicher/Garage', 'siegel-umzugsliste' ),
);
}
/**
* Get furniture items with translatable names
*/
private static function get_all_furniture_data() {
return array(
'wohnzimmer' => array(
array(
'name' => __( 'Sofa, Couch, je Sitz', 'siegel-umzugsliste' ),
'cbm' => 0.4,
'montage' => true
),
array(
'name' => __( 'Sessel mit Armlehne', 'siegel-umzugsliste' ),
'cbm' => 0.8,
'montage' => true
),
// ... rest of items
),
);
}
}
```
### Form Rendering with Escaped Translations
```php
// Source: https://developer.wordpress.org/apis/security/escaping/
class Umzugsliste_Form_Renderer {
public static function render_field( $field_name ) {
?>
<label>
<?php esc_html_e( 'Customer Name', 'siegel-umzugsliste' ); ?>
</label>
<input
type="text"
name="<?php echo esc_attr( $field_name ); ?>"
placeholder="<?php esc_attr_e( 'Enter your name', 'siegel-umzugsliste' ); ?>"
>
<?php
}
public static function render_submit_button() {
?>
<button type="submit">
<?php esc_html_e( 'Submit Moving List', 'siegel-umzugsliste' ); ?>
</button>
<?php
}
}
```
### Validation Messages with Variables
```php
// Source: https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/
class Umzugsliste_Form_Handler {
private function validate_date( $day, $month, $year ) {
if ( empty( $day ) || empty( $month ) || empty( $year ) ) {
return sprintf(
/* translators: %s: field name that is missing */
esc_html__( 'Please enter %s', 'siegel-umzugsliste' ),
esc_html__( 'moving date', 'siegel-umzugsliste' )
);
}
if ( ! checkdate( $month, $day, $year ) ) {
return esc_html__( 'Invalid date. Please check your entry.', 'siegel-umzugsliste' );
}
return '';
}
private function validate_cbm_total( $total_cbm ) {
if ( $total_cbm <= 0 ) {
return esc_html__( 'Please select at least one furniture item.', 'siegel-umzugsliste' );
}
if ( $total_cbm > 1000 ) {
return sprintf(
/* translators: %d: maximum allowed cubic meters */
esc_html__( 'Total volume exceeds maximum of %d cubic meters.', 'siegel-umzugsliste' ),
1000
);
}
return '';
}
}
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Manual `load_plugin_textdomain()` required | Optional for WP.org plugins (4.6+) | WordPress 4.6 (2016) | WP.org plugins auto-load from translate.wordpress.org, but manual loading still needed for local files and Loco Translate |
| `xgettext` for POT extraction | `wp i18n make-pot` WP-CLI command | WP-CLI 2.1.0 (2019) | WordPress-aware extraction, handles edge cases, JS support, simpler workflow |
| Email always in site locale | `switch_to_locale()` for per-user language | WordPress 4.7 (2016) | Can send emails in recipient's language, but has plugin translation bug (#39210) requiring workaround |
| Manual JSON file generation for JS | `wp_set_script_translations()` auto-generates | WordPress 5.0 (2018) | Gutenberg blocks can use same POT file, automatic JSON extraction from PO files |
| Separate fallback locale logic | Native fallback chains proposed | Ticket #28197 (proposed, not in core) | Still requires Language Fallback plugin for locale family fallback (es_MX → es_ES) |
**Deprecated/outdated:**
- **`pomo` library direct usage:** WordPress core abstracts this, use `load_plugin_textdomain()` instead
- **`makepot.php` script:** Replaced by WP-CLI `wp i18n make-pot`, more reliable and maintained
- **Domain Path without leading slash:** Modern convention is `/languages`, old plugins used `languages` (no slash) - both work but `/languages` is standard
- **`languages_loaded` action for text domain loading:** Use `init` hook instead, `languages_loaded` was never officially documented
## Open Questions
Things that couldn't be fully resolved:
1. **German fallback for unsupported locales**
- What we know: WordPress doesn't natively fall back to de_DE for non-German, non-English locales (e.g., fr_FR with no French translation would show English, not German)
- What's unclear: Whether this is important enough to warrant the Language Fallback plugin, or if just shipping de_DE and en_US translations is sufficient
- Recommendation: Start without Language Fallback plugin. If a site uses a locale other than de_DE or en_US and lacks that translation, it will fall back to English (the strings in code). Only add Language Fallback if users actually need de_DE as ultimate fallback instead of English. Decision marked for Claude's discretion in CONTEXT.md.
2. **Locale switching vs. separate German data**
- What we know: `switch_to_locale()` works but requires `change_locale` hook workaround, separate German data array avoids translation system entirely
- What's unclear: Which approach is more maintainable long-term and less fragile
- Recommendation: Implement `switch_to_locale()` with the `change_locale` workaround first (standard WordPress pattern). If it proves problematic during testing, fall back to separate German data array. Locale switching is more "WordPress-native" and keeps data in one place.
3. **JavaScript localization scope**
- What we know: Frontend validation uses JavaScript and shows error messages
- What's unclear: Whether current JS validation messages are inline in HTML (easy to translate via PHP) or in separate JS files (requires `wp_localize_script()` or `wp_set_script_translations()`)
- Recommendation: Inspect existing JS validation implementation. If messages are in separate JS file, use `wp_localize_script()` to pass translated strings from PHP. If messages are inline in PHP-rendered HTML, wrap them in `esc_js( __() )`.
4. **POT file freshness in version control**
- What we know: POT should be regenerated before each release, but committing it to git means it can go stale between releases
- What's unclear: Whether to commit POT file to git or generate it only for releases
- Recommendation: Commit POT file to git for Loco Translate compatibility on development installs, and add "regenerate POT" step to release checklist. Alternative: Use git hooks to auto-regenerate on commit, but that adds complexity.
## Sources
### Primary (HIGH confidence)
- [How to Internationalize Your Plugin WordPress Plugin Handbook](https://developer.wordpress.org/plugins/internationalization/how-to-internationalize-your-plugin/) - Official WordPress plugin i18n guide
- [switch_to_locale() WordPress Function Reference](https://developer.wordpress.org/reference/functions/switch_to_locale/) - Official function documentation
- [wp i18n make-pot WP-CLI Command](https://developer.wordpress.org/cli/commands/i18n/make-pot/) - Official WP-CLI POT generation tool
- [Loco Translate - Help for Authors](https://localise.biz/wordpress/plugin/authors) - Official Loco Translate plugin compatibility guide
- [Escaping Data WordPress Common APIs Handbook](https://developer.wordpress.org/apis/security/escaping/) - Official escaping function documentation
### Secondary (MEDIUM confidence)
- [WordPress i18n best practices (2026 blog post)](https://aki-hamano.blog/en/2026/02/04/wordpress-i18n/) - Recent developer blog with updated practices
- [switch_to_locale() unloads plugin translations WordPress Trac #39210](https://core.trac.wordpress.org/ticket/39210) - Known bug and workaround discussions (WebSearch reference, direct access blocked)
- [How to translate emails? - Polylang Documentation](https://polylang.pro/doc/how-to-translate-emails/) - Confirms locale switching pattern for emails
- [Language Fallback Plugin](https://wordpress.org/plugins/language-fallback/) - Solution for locale family fallback
### Tertiary (LOW confidence)
- [Text Domain in WordPress Internationalization - Pascal Birchler](https://pascalbirchler.com/text-domain-wordpress-internationalization/) - Community blog post about text domain requirements, not official but well-regarded source
- [WordPress wp_localize_script alternatives discussion](https://roots.io/stop-using-wp_localize_script-to-pass-data/) - Community discussion about when NOT to use wp_localize_script (for data vs. actual localization)
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - WordPress i18n API is stable and well-documented since WP 2.1, gettext is industry standard
- Architecture: HIGH - Official handbook provides clear patterns, WP-CLI and Loco Translate are de facto standards
- Pitfalls: HIGH - Known issues like #39210 are documented in Trac, text domain matching is enforced by WP.org, variable interpolation bugs are common and well-known
- Email locale switching: MEDIUM - Pattern is documented but workaround required due to core bug, needs testing to confirm effectiveness
- German fallback: LOW - Language Fallback plugin exists but unclear if needed for this use case
**Research date:** 2026-02-06
**Valid until:** 2026-03-08 (30 days - WordPress i18n is stable, patterns unlikely to change rapidly)
**Critical action items for planning:**
1. Change text domain from `umzugsliste` to `siegel-umzugsliste` throughout codebase
2. Implement `change_locale` hook workaround for `switch_to_locale()` bug
3. Decide: locale switching vs. separate German data for email generation (recommend trying locale switching first)
4. Decide: ship German PO/MO files or only POT template (recommend shipping both for out-of-box German support)