Atualizado em 19/06/2026
Internacionalizar (i18n) é deixar o seu código pronto para ser traduzido sem ter de editá-lo — e essa é uma das marcas que separam um plugin amador de um profissional. O WordPress resolve o problema com a biblioteca gettext: você envolve cada texto visível ao usuário em uma função (__(), _e(), _n()…), informa um text domain que identifica o seu projeto, e a partir daí um tradutor pode produzir os arquivos de cada idioma sem nunca abrir o seu PHP. Parece simples, mas há nuances — text domain literal, placeholders numerados, comentários para tradutores — que, ignoradas, fazem strings "desaparecerem" do catálogo. Vamos a elas.
Por que internacionalizar (e por que cedo)
Mesmo que o seu público hoje seja só o Brasil, há motivos concretos para internacionalizar desde a primeira versão:
- Distribuição: para publicar no WordPress.org, a internacionalização é praticamente obrigatória — é o que permite à comunidade traduzir o seu plugin via translate.wordpress.org.
- Custo de retrofit: voltar a um plugin com centenas de strings cruas para envolvê-las uma a uma é caro e arriscado. Fazer no momento em que se escreve a string custa segundos.
- Consistência: o mesmo mecanismo serve para padronizar termos, ajustar plurais e dar contexto — qualidade que se reflete mesmo num site monolíngue.
A internacionalização é trabalho do desenvolvedor; a tradução em si (a localização, ou l10n) é feita depois, sobre os arquivos que você gera. Aqui tratamos da primeira metade — a que vive no código.
O text domain: a regra que mais gera bug
Toda string traduzível carrega um text domain: um identificador que diz a qual projeto aquela tradução pertence. É o segundo argumento das funções gettext. Três regras inegociáveis:
- O text domain deve ser idêntico ao slug do plugin — a pasta/arquivo registrado no .org. Se o plugin é
minha-loja, o domain éminha-loja. - Ele tem de ser uma string literal, escrita à mão em cada chamada. Nunca use uma variável, constante ou concatenação — o extrator de strings lê o código sem executá-lo e não conseguiria resolver o valor.
- O texto a traduzir também é literal, pela mesma razão. Não monte a frase com variáveis dentro de
__().
Um text domain digitado errado em uma única chamada (um 'minha_loja' com underscore onde o resto usa 'minha-loja') faz aquela string nunca encontrar a tradução — e o bug é silencioso: o texto simplesmente aparece em inglês. Padronize o domain e, na dúvida, deixe o linter de i18n do wp i18n apontar divergências.
Declarando o text domain e carregando as traduções
No cabeçalho do plugin, declare Text Domain (e, por convenção histórica, Domain Path). Em seguida, registre o carregamento dos arquivos .mo a partir de uma pasta /languages:
<?php
/**
* Plugin Name: Minha Loja
* Description: Exemplo de plugin internacionalizado.
* Version: 1.0.0
* Text Domain: minha-loja
* Domain Path: /languages
*/
add_action( 'init', 'minha_loja_load_textdomain' );
function minha_loja_load_textdomain() {
load_plugin_textdomain(
'minha-loja', // text domain (= slug)
false, // parâmetro legado, sempre false
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
Desde o WordPress 4.6, plugins e temas hospedados no .org têm as traduções carregadas automaticamente a partir de translate.wordpress.org (o chamado just-in-time loading), e a chamada load_plugin_textdomain() passa a ser opcional nesse cenário. Ela continua indispensável para produtos distribuídos por conta própria, em que você embute os .mo em /languages. Mantê-la é seguro nos dois casos. Observação de versão: o load_plugin_textdomain() deve rodar no máximo no hook init — chamá-lo cedo demais (em plugins_loaded antes do init, ou no topo do arquivo) pode preceder o momento em que o WordPress já sabe o locale do usuário; a partir do WP 6.7 o próprio núcleo passou a emitir um aviso (_load_textdomain_just_in_time) quando uma tradução é solicitada cedo demais.
As funções gettext: traduzir, imprimir, escapar
Existe uma função para cada combinação de "retornar ou imprimir" e "escapar ou não". Memorize a lógica dos nomes — ela é consistente:
__()(dois sublinhados) retorna a string traduzida._e()imprime (ecoa) a string traduzida — éecho __().- O prefixo
esc_html_ouesc_attr_adiciona o escaping de saída no mesmo passo:esc_html__()traduz e retorna escapado para HTML;esc_attr_e()traduz e imprime escapado para atributo. _n()resolve plural;_x()/_ex()adicionam contexto para desambiguar.
<?php
// Retorna (para usar numa variável, num return, etc.)
$titulo = __( 'Configurações', 'minha-loja' );
// Imprime direto
_e( 'Salvar alterações', 'minha-loja' );
// Traduz E escapa na saída (a forma recomendada ao ecoar em HTML)
echo esc_html__( 'Bem-vindo à sua loja', 'minha-loja' );
// Dentro de um atributo:
printf( '<input type="submit" value="%s">', esc_attr__( 'Enviar', 'minha-loja' ) );
// Plural: _n( singular, plural, número, domain )
$n = count( $itens );
$texto = sprintf(
_n( '%s item no carrinho', '%s itens no carrinho', $n, 'minha-loja' ),
number_format_i18n( $n )
);
// Contexto com _x(): a MESMA palavra, sentidos diferentes
$rotulo_botao = _x( 'Post', 'verbo, publicar conteúdo', 'minha-loja' );
$rotulo_tipo = _x( 'Post', 'substantivo, um artigo', 'minha-loja' );
Por que _x() existe? Porque uma palavra curta em inglês pode ter traduções diferentes conforme o sentido. "Post" pode ser o verbo "publicar" ou o substantivo "artigo"; em português viram palavras distintas. O contexto (segundo argumento) faz cada ocorrência aparecer separadamente no catálogo, permitindo ao tradutor escolher a palavra certa para cada uma.
Referência rápida das funções
| Função | O que faz | Retorna ou imprime | Escapa? |
|---|---|---|---|
__() | Traduz | Retorna | Não |
_e() | Traduz | Imprime | Não |
esc_html__() | Traduz + escapa p/ HTML | Retorna | Sim (HTML) |
esc_html_e() | Traduz + escapa p/ HTML | Imprime | Sim (HTML) |
esc_attr__() | Traduz + escapa p/ atributo | Retorna | Sim (atributo) |
esc_attr_e() | Traduz + escapa p/ atributo | Imprime | Sim (atributo) |
_n() | Singular / plural por número | Retorna | Não |
_x() | Traduz com contexto | Retorna | Não |
_ex() | Traduz com contexto | Imprime | Não |
_nx() | Plural + contexto | Retorna | Não |
Regra de bolso: ao ecoar texto em HTML, prefira sempre a variante esc_html_* ou esc_attr_*. Você ganha tradução e escaping de saída num único passo — duas boas práticas pelo preço de uma.
Variáveis em strings: sprintf e placeholders numerados
Aqui mora um dos erros mais comuns. Você nunca concatena uma variável dentro de __(), porque isso quebra a string em pedaços que o tradutor não consegue traduzir como uma frase. A forma correta é deixar a frase inteira como placeholder e injetar o valor com sprintf():
<?php
$nome = 'Roger';
$total = 3;
// ERRADO: concatenação quebra a frase e impede a tradução
echo __( 'Olá, ', 'minha-loja' ) . $nome . __( '! Você tem ', 'minha-loja' ) . $total . __( ' pedidos.', 'minha-loja' );
// CERTO: frase inteira traduzível + sprintf com placeholders NUMERADOS
/* translators: 1: nome do cliente, 2: quantidade de pedidos */
echo sprintf(
esc_html__( 'Olá, %1$s! Você tem %2$d pedidos.', 'minha-loja' ),
esc_html( $nome ),
(int) $total
);
Por que numerar (%1$s, %2$d) e não usar %s/%d simples? Porque a ordem das palavras varia entre idiomas. Com placeholders numerados, o tradutor pode escrever, por exemplo, "Você tem %2$d pedidos, %1$s!" — reordenando os argumentos sem tocar no seu PHP. Com placeholders sem número, a ordem fica presa à sequência dos argumentos passados ao sprintf(). Use a forma simples apenas quando houver um único placeholder.
O comentário para tradutores
Sempre que uma string tem placeholders ou é ambígua, deixe um comentário /* translators: ... */ na linha imediatamente acima da chamada gettext. O extrator anexa esse comentário à string no .pot, e é ele que diz ao tradutor o que cada %s significa:
<?php
/* translators: %s = título do produto */
$msg = sprintf( esc_html__( 'O produto "%s" foi adicionado ao carrinho.', 'minha-loja' ), esc_html( $titulo ) );
/* translators: 1: data do pedido, 2: valor total formatado */
$resumo = sprintf(
esc_html__( 'Pedido feito em %1$s, total de %2$s.', 'minha-loja' ),
esc_html( $data ),
esc_html( $valor )
);
O comentário precisa começar exatamente com a palavra translators: e estar colado à linha da função (sem linhas em branco entre eles) para que o make-pot o capture. Um comentário bem-intencionado mas mal posicionado simplesmente não chega ao tradutor.
Gerando o catálogo: o fluxo .pot → .po → .mo
Com o código internacionalizado, o próximo passo é gerar o template de tradução (.pot), que lista todas as strings extraídas. A ferramenta oficial é o WP-CLI:
# Na raiz do plugin, gera languages/minha-loja.pot
wp i18n make-pot . languages/minha-loja.pot --domain=minha-loja
# (opcional) gerar os .json para as traduções de JavaScript
wp i18n make-json languages/ --no-purge
O ciclo completo de arquivos é:
- .pot (Portable Object Template): o catálogo-mestre, sem traduções, gerado do seu código. É o que você distribui para os tradutores.
- .po (Portable Object): uma cópia do
.potpreenchida com as traduções de um idioma (ex.:minha-loja-pt_BR.po). Editável em ferramentas como o Poedit ou no GlotPress. - .mo (Machine Object): a versão binária e compilada do
.po, que é o que o WordPress realmente lê em tempo de execução. Gerada a partir do.po(o Poedit faz ao salvar; o WP-CLI faz comwp i18n make-mo).
A convenção de nomes dos arquivos importa: para plugins, o padrão é {textdomain}-{locale}.mo — por exemplo, minha-loja-pt_BR.mo. Errar o locale ou o domain no nome do arquivo faz o WordPress não encontrar a tradução, mesmo com tudo o mais correto.
i18n no JavaScript
Strings em blocos do editor, em scripts do admin ou no front-end também precisam ser traduzíveis. O WordPress expõe o pacote @wordpress/i18n (acessível como wp.i18n), com as mesmas funções do PHP, e você conecta o catálogo ao script com wp_set_script_translations() no momento do enfileiramento:
<?php
// PHP: enfileira o script e conecta as traduções (arquivos .json em /languages)
add_action( 'wp_enqueue_scripts', 'minha_loja_scripts' );
function minha_loja_scripts() {
wp_enqueue_script(
'minha-loja-front',
plugins_url( 'js/front.js', __FILE__ ),
array( 'wp-i18n' ), // dependência obrigatória
'1.0.0',
true
);
wp_set_script_translations( 'minha-loja-front', 'minha-loja', plugin_dir_path( __FILE__ ) . 'languages' );
}
No lado do JavaScript, importe e use __() normalmente. Lembre-se: a tradução de JS usa arquivos .json (gerados com wp i18n make-json), não .mo — mas o text domain é o mesmo do PHP.
Armadilhas comuns
| Sintoma | Causa provável | Correção |
|---|---|---|
| String aparece em inglês mesmo traduzida | Text domain divergente do slug (ou digitado errado) | Padronize o domain literal em todas as chamadas |
String não aparece no .pot | Texto ou domain passado como variável | Use sempre strings literais nos dois argumentos |
| Frase impossível de traduzir bem | Concatenação de variáveis dentro de __() | Frase inteira + sprintf() com %1$s |
Tradutor não sabe o que é cada %s | Falta o comentário /* translators: */ | Adicione-o na linha logo acima da função |
| Aviso "loaded too early" no WP 6.7+ | load_plugin_textdomain ou string traduzida antes do init | Carregue no hook init; não traduza no topo do arquivo |
| Mesma palavra com duas traduções necessárias | Falta de contexto | Use _x() para desambiguar |
Boas práticas
- Envolva a string no momento em que a escreve. Não deixe para "internacionalizar depois" — o retrofit é o caminho mais caro.
- Prefira as variantes que escapam (
esc_html__(),esc_attr_e()) ao ecoar em HTML: tradução e segurança no mesmo passo. - Mantenha o text domain idêntico ao slug e sempre literal. Rode o linter do
wp i18nno CI para pegar divergências cedo. - Numere os placeholders a partir de dois argumentos e comente cada um com
/* translators: */. - Use
number_format_i18n()edate_i18n()para números e datas — a formatação também muda por locale. - Não traduza fora de contexto de usuário: nomes de hooks, chaves de array e identificadores internos não vão para o catálogo. Internacionalize apenas o que é visível.
Internacionalizar é, no fim, uma questão de disciplina com um punhado de funções — exatamente como acontece com hooks e com a segurança. Feito desde o primeiro commit do seu plugin, custa segundos por string e abre o seu produto para o mundo inteiro.
Perguntas frequentes
Qual a diferença entre i18n e l10n?
Internacionalização (i18n) é o trabalho que você, desenvolvedor, faz no código: envolver cada string em uma função gettext (__(), _e() etc.) e declarar um text domain, de modo que o texto possa ser traduzido sem editar o programa. Localização (l10n) é o passo seguinte, feito por tradutores: gerar os arquivos .po/.mo de cada idioma a partir do .pot. Em resumo: i18n prepara, l10n traduz. As abreviações vêm de "internationalization" (18 letras entre o i e o n) e "localization" (10 letras).
Por que o text domain não pode ser uma variável?
Porque o gerador do arquivo .pot (o wp i18n make-pot) lê o código estaticamente, sem executá-lo. Ele precisa ver o text domain escrito como string literal — __( 'Salvar', 'meu-plugin' ) — para extrair a string corretamente. Se você passar uma variável ou uma constante (__( 'Salvar', $dominio )), a ferramenta não consegue resolver o valor e a string fica de fora do catálogo, ou gera um aviso. Pela mesma razão, o próprio texto a traduzir também deve ser literal, nunca uma variável.
Ainda preciso de load_plugin_textdomain() se publico no WordPress.org?
Para plugins e temas hospedados no repositório oficial, não é mais obrigatório: desde o WordPress 4.6 o núcleo carrega automaticamente as traduções fornecidas pelo sistema translate.wordpress.org (a chamada "just-in-time" loading). Porém, o load_plugin_textdomain() continua válido e necessário sempre que você distribui o produto por conta própria (venda direta, cliente privado, plugin premium) e embute os arquivos .mo em uma pasta /languages. Mantê-lo não causa problema; removê-lo quebra a tradução fora do .org.
Por que usar %1$s e %2$s em vez de %s?
Porque a ordem das palavras muda entre idiomas. Com placeholders numerados (%1$s, %2$s), o tradutor pode reordenar os argumentos na frase sem tocar no seu código PHP — em algumas línguas o objeto vem antes do sujeito. Com %s simples, a ordem fica travada na sequência em que você passou os argumentos ao sprintf(). Use %s apenas quando houver um único placeholder; com dois ou mais, sempre numere.
O que é o comentário /* translators: */ e por que ele importa?
É um comentário especial, colocado na linha imediatamente acima da função gettext, que o make-pot extrai e anexa à string no arquivo .pot. Ele dá contexto ao tradutor: o que cada %s representa, se a palavra é verbo ou substantivo, qual o tom. Sem ele, o tradutor recebe "%s de %s" sem saber o que preencher, e a tradução sai errada. É exigência das diretrizes do WordPress.org sempre que a string contém placeholders.
Como traduzir strings que estão no JavaScript?
Use o pacote @wordpress/i18n (wp.i18n), que expõe __(), _n() e _x() no lado do JS, e conecte o catálogo ao script enfileirado com wp_set_script_translations( 'handle', 'meu-plugin' ) no PHP. O fluxo de arquivos muda: as traduções de JS usam arquivos .json (gerados a partir do .po com wp i18n make-json), não .mo. O text domain, contudo, é o mesmo do PHP.