| name | wp-bilingual |
| description | Bilingual/multilingual i18n methodology using ACF _suffix pattern with transparent translation helpers |
| user-invocable | false |
Bilingual / Multilingual i18n System
This skill defines the translation methodology for WordPress themes that need to support multiple languages. It uses the ACF/SCF _suffix pattern -- no WPML, no Polylang, no separate pages per language. One set of pages, one set of fields, with suffixed duplicates for secondary languages.
Core Concept: The _suffix Pattern
For every translatable ACF/SCF field, the primary language (typically English) uses the base field name. Each secondary language gets a duplicate field with a language suffix appended.
| Primary Field (EN) | Spanish Field | French Field |
|---|
hero_title | hero_title_es | hero_title_fr |
hero_subtitle | hero_subtitle_es | hero_subtitle_fr |
cta_button_text | cta_button_text_es | cta_button_text_fr |
service_description | service_description_es | service_description_fr |
Rules
- Primary language fields have no suffix and are always required
- Secondary language fields have
_<lang> suffix and are optional (fall back to primary if empty)
- This applies to text, textarea, WYSIWYG, and any content field
- Non-translatable fields (images, URLs, numbers, booleans) do NOT get duplicated
- ACF field instructions for secondary fields should say: "Leave empty to use English version"
Configuration Constants
Define supported languages and the default at the top of inc/i18n.php.
define('PREFIX_SUPPORTED_LANGS', array('en', 'es'));
define('PREFIX_DEFAULT_LANG', 'en');
Language Detection
Language is detected using a strict priority chain. The first match wins.
Priority: URL parameter > Cookie > Browser Accept-Language > Default
function prefix_get_current_lang() {
static $current_lang = null;
if ($current_lang !== null) {
return $current_lang;
}
if (isset($_GET['lang']) && in_array($_GET['lang'], PREFIX_SUPPORTED_LANGS)) {
$current_lang = sanitize_text_field($_GET['lang']);
setcookie('prefix_lang', $current_lang, time() + (365 * 24 * 60 * 60), '/');
return $current_lang;
}
if (isset($_COOKIE['prefix_lang']) && in_array($_COOKIE['prefix_lang'], PREFIX_SUPPORTED_LANGS)) {
$current_lang = sanitize_text_field($_COOKIE['prefix_lang']);
return $current_lang;
}
if (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
$browser_lang = substr($_SERVER['HTTP_ACCEPT_LANGUAGE'], 0, 2);
if (in_array($browser_lang, PREFIX_SUPPORTED_LANGS)) {
$current_lang = $browser_lang;
return $current_lang;
}
}
$current_lang = PREFIX_DEFAULT_LANG;
return $current_lang;
}
Important Notes on Cookies
setcookie() MUST be called before any HTML output (before headers are sent)
- The
i18n.php file must be included early in functions.php, before any template rendering
- Cookie path is
/ so it works across all pages
- Cookie lifetime: 365 days
Cookie Persistence
When the user clicks a language switcher link (e.g., ?lang=es), the cookie is set in the prefix_get_current_lang() function. Subsequent page loads read the cookie, so the URL parameter is only needed once.
setcookie('prefix_lang', $current_lang, time() + (365 * 24 * 60 * 60), '/');
Translation Helper Functions
prefix_get_field() -- Auto-Translating Field Getter
This is the primary function for retrieving any ACF/SCF field. It checks the current language, tries the suffixed field first, and falls back to the primary field.
function prefix_get_field($field_name, $post_id = null) {
$lang = prefix_get_current_lang();
if ($lang !== PREFIX_DEFAULT_LANG) {
$translated_field = $field_name . '_' . $lang;
$value = get_field($translated_field, $post_id);
if (!empty($value)) {
return $value;
}
}
return get_field($field_name, $post_id);
}
Usage in templates:
<h1><?php echo esc_html(prefix_get_field('hero_title')); ?></h1>
<p><?php echo wp_kses_post(prefix_get_field('hero_description')); ?></p>
<!-- With post ID -->
<?php $logo = prefix_get_field('site_logo', 'option'); ?>
<!-- With specific post -->
<?php $title = prefix_get_field('custom_title', $post->ID); ?>
prefix_get_repeater() -- Repeater Field Translation
Translates specific subfields within a repeater while leaving non-translatable subfields (images, URLs) untouched.
function prefix_get_repeater($field_name, $translatable_subfields = array(), $post_id = null) {
$lang = prefix_get_current_lang();
$repeater = get_field($field_name, $post_id);
if (!$repeater || !is_array($repeater)) {
return array();
}
if ($lang === PREFIX_DEFAULT_LANG || empty($translatable_subfields)) {
return $repeater;
}
foreach ($repeater as $index => $row) {
foreach ($translatable_subfields as $subfield) {
$translated_key = $subfield . '_' . $lang;
if (isset($row[$translated_key]) && !empty($row[$translated_key])) {
$repeater[$index][$subfield] = $row[$translated_key];
}
}
}
return $repeater;
}
Usage:
$services = prefix_get_repeater('services', array('title', 'description'));
foreach ($services as $service) : ?>
<div class="service-card">
<img src="<?php echo esc_url($service['icon']['url']); ?>" alt="">
<h3><?php echo esc_html($service['title']); ?></h3>
<p><?php echo esc_html($service['description']); ?></p>
</div>
<?php endforeach;
prefix_get_sub_field() -- Sub-field Translation Inside Loops
Used inside have_rows() loops (repeaters, flexible content) to get translated subfield values.
function prefix_get_sub_field($field_name) {
$lang = prefix_get_current_lang();
if ($lang !== PREFIX_DEFAULT_LANG) {
$value = get_sub_field($field_name . '_' . $lang);
if (!empty($value)) {
return $value;
}
}
return get_sub_field($field_name);
}
Usage inside have_rows():
<?php if (have_rows('team_members')) : ?>
<?php while (have_rows('team_members')) : the_row(); ?>
<div class="team-member">
<h3><?php echo esc_html(prefix_get_sub_field('name')); ?></h3>
<p><?php echo esc_html(prefix_get_sub_field('bio')); ?></p>
</div>
<?php endwhile; ?>
<?php endif; ?>
prefix_t() and prefix_e() -- Static UI String Translation
For hardcoded UI strings (navigation labels, button text, form labels) that do not come from ACF fields.
function prefix__($key) {
$lang = prefix_get_current_lang();
$translations = prefix_get_translations();
if (isset($translations[$key][$lang])) {
return $translations[$key][$lang];
}
if (isset($translations[$key][PREFIX_DEFAULT_LANG])) {
return $translations[$key][PREFIX_DEFAULT_LANG];
}
return $key;
}
function prefix_e($key) {
echo esc_html(prefix__($key));
}
Usage:
<!-- In templates -->
<a href="#services"><?php prefix_e('nav_services'); ?></a>
<button><?php prefix_e('btn_learn_more'); ?></button>
<!-- When you need the raw string (e.g., for attributes) -->
<a href="#" aria-label="<?php echo esc_attr(prefix__('nav_schedule')); ?>">
prefix_is_lang() and prefix_get_current_lang()
Convenience helpers for language checks.
function prefix_is_lang($lang) {
return prefix_get_current_lang() === $lang;
}
function prefix_is_spanish() {
return prefix_get_current_lang() === 'es';
}
Usage:
<?php if (prefix_is_spanish()) : ?>
<html lang="es">
<?php else : ?>
<html lang="en">
<?php endif; ?>
Static Translations Array
Define all hardcoded UI strings in a central translations function. Each entry is an associative array keyed by language code.
function prefix_get_translations() {
return array(
'nav_home' => array(
'en' => 'Home',
'es' => 'Inicio',
),
'nav_services' => array(
'en' => 'Services',
'es' => 'Servicios',
),
'nav_pricing' => array(
'en' => 'Pricing',
'es' => 'Precios',
),
'nav_contact' => array(
'en' => 'Contact',
'es' => 'Contacto',
),
'btn_learn_more' => array(
'en' => 'Learn More',
'es' => 'Saber Mas',
),
'btn_get_started' => array(
'en' => 'Get Started',
'es' => 'Comenzar',
),
'btn_schedule' => array(
'en' => 'Schedule Appointment',
'es' => 'Agendar Cita',
),
'footer_services' => array(
'en' => 'Services',
'es' => 'Servicios',
),
'footer_quick_links' => array(
'en' => 'Quick Links',
'es' => 'Enlaces Rapidos',
),
'footer_privacy' => array(
'en' => 'Privacy Policy',
'es' => 'Politica de Privacidad',
),
'footer_terms' => array(
'en' => 'Terms & Conditions',
'es' => 'Terminos y Condiciones',
),
'social_follow_us' => array(
'en' => 'Follow Us',
'es' => 'Siguenos',
),
);
}
JavaScript Translations
For strings needed in client-side JS, create a filtered subset and pass via wp_localize_script().
function prefix_get_js_translations() {
$all = prefix_get_translations();
$lang = prefix_get_current_lang();
$js_strings = array();
$js_keys = array('btn_learn_more', 'btn_schedule', 'calc_per_month');
foreach ($js_keys as $key) {
if (isset($all[$key][$lang])) {
$js_strings[$key] = $all[$key][$lang];
}
}
return $js_strings;
}
Language Switcher URL Generation
Use remove_query_arg() and add_query_arg() to build language toggle URLs.
function prefix_get_lang_url($lang) {
$url = remove_query_arg('lang');
return add_query_arg('lang', $lang, $url);
}
Language switcher in a template:
<div class="lang-switcher">
<?php $current_lang = prefix_get_current_lang(); ?>
<?php foreach (PREFIX_SUPPORTED_LANGS as $lang) : ?>
<?php if ($lang !== $current_lang) : ?>
<a href="<?php echo esc_url(prefix_get_lang_url($lang)); ?>"
class="lang-switcher__link"
aria-label="<?php echo esc_attr('Switch to ' . strtoupper($lang)); ?>">
<?php echo esc_html(strtoupper($lang)); ?>
</a>
<?php endif; ?>
<?php endforeach; ?>
</div>
Menu Locations: Per-Language Pattern
Register separate menu locations for each language. This allows admins to create fully localized menus in wp-admin.
Registration
function prefix_setup() {
register_nav_menus(array(
'primary-en' => __('Primary Navigation (EN)', 'theme-slug'),
'primary-es' => __('Primary Navigation (ES)', 'theme-slug'),
'mobile-en' => __('Mobile Navigation (EN)', 'theme-slug'),
'mobile-es' => __('Mobile Navigation (ES)', 'theme-slug'),
'footer-en' => __('Footer Navigation (EN)', 'theme-slug'),
'footer-es' => __('Footer Navigation (ES)', 'theme-slug'),
));
}
add_action('after_setup_theme', 'prefix_setup');
Usage in Templates
Select the menu location dynamically based on the current language.
<?php
$lang = prefix_get_current_lang();
wp_nav_menu(array(
'theme_location' => 'primary-' . $lang,
'container' => false,
'fallback_cb' => 'prefix_nav_fallback',
'items_wrap' => '%3$s',
'walker' => new Prefix_Nav_Walker(),
));
?>
The pattern is: <location>-<lang> (e.g., primary-en, primary-es, mobile-en, mobile-es).
ACF Field Creation Rules
When defining fields in inc/scf-fields.php for a bilingual site:
Field Organization
Use Tab fields to organize languages in the admin UI.
array(
'key' => 'field_hero_tab_en',
'label' => 'English',
'type' => 'tab',
'placement' => 'top',
),
array(
'key' => 'field_hero_title',
'label' => 'Hero Title',
'name' => 'hero_title',
'type' => 'text',
'required' => 1,
),
array(
'key' => 'field_hero_description',
'label' => 'Hero Description',
'name' => 'hero_description',
'type' => 'textarea',
'required' => 1,
),
array(
'key' => 'field_hero_tab_es',
'label' => 'Espanol',
'type' => 'tab',
'placement' => 'top',
),
array(
'key' => 'field_hero_title_es',
'label' => 'Hero Title (ES)',
'name' => 'hero_title_es',
'type' => 'text',
'instructions' => 'Leave empty to use English version.',
'required' => 0,
),
array(
'key' => 'field_hero_description_es',
'label' => 'Hero Description (ES)',
'name' => 'hero_description_es',
'type' => 'textarea',
'instructions' => 'Leave empty to use English version.',
'required' => 0,
),
Repeater Subfields
Inside repeaters, add suffixed subfields for each translatable text subfield.
array(
'key' => 'field_services',
'label' => 'Services',
'name' => 'services',
'type' => 'repeater',
'sub_fields' => array(
array(
'key' => 'field_service_icon',
'label' => 'Icon',
'name' => 'icon',
'type' => 'image',
),
array(
'key' => 'field_service_title',
'label' => 'Title (EN)',
'name' => 'title',
'type' => 'text',
),
array(
'key' => 'field_service_title_es',
'label' => 'Title (ES)',
'name' => 'title_es',
'type' => 'text',
'instructions' => 'Leave empty to use English version.',
),
array(
'key' => 'field_service_description',
'label' => 'Description (EN)',
'name' => 'description',
'type' => 'textarea',
),
array(
'key' => 'field_service_description_es',
'label' => 'Description (ES)',
'name' => 'description_es',
'type' => 'textarea',
'instructions' => 'Leave empty to use English version.',
),
array(
'key' => 'field_service_link',
'label' => 'Link',
'name' => 'link',
'type' => 'url',
),
),
),
Critical Rule: Templates ALWAYS Use prefix_get_field()
Templates must NEVER call get_field() directly. Always use the translation-aware wrapper.
$title = get_field('hero_title');
$title = prefix_get_field('hero_title');
This rule applies everywhere:
prefix_get_field() instead of get_field()
prefix_get_sub_field() instead of get_sub_field()
prefix_get_repeater() instead of raw get_field() on repeaters
prefix__() / prefix_e() instead of hardcoded strings
The only place get_field() is called directly is inside the helper functions themselves.
Setting the HTML lang Attribute
In header.php, set the document language dynamically.
<!DOCTYPE html>
<html <?php language_attributes(); ?> lang="<?php echo esc_attr(prefix_get_current_lang()); ?>">
<head>
<meta charset="<?php bloginfo('charset'); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
File Structure
The i18n system lives in a single file included early in functions.php.
require get_template_directory() . '/inc/i18n.php';
require get_template_directory() . '/inc/scf-fields.php';
The inc/i18n.php file contains:
- Language constants
prefix_get_current_lang()
prefix_get_field()
prefix_get_repeater()
prefix_get_sub_field()
prefix__() and prefix_e()
prefix_get_lang_url()
prefix_is_spanish() / prefix_is_lang()
prefix_get_translations() (the static strings array)
prefix_get_js_translations()
Summary Checklist