Por que o fluxo é indireto
O autor de um plugin escreve inglês (e opcionalmente algumas traduções primárias) na árvore de source. Admins de produção querem editar as strings no install rodando deles — corrigir um typo, suavizar um label, traduzir um locale extra — sem nunca abrir o source code do plugin. Ambas as audiências precisam trabalhar com o mesmo conjunto de keys, mas elas não podem compartilhar o mesmo arquivo: editar o source numa instância de produção é apagado no próximo upgrade do plugin, e editar uma cópia deployada que espelha o source significa que o arquivo source-controlled nunca vê o fix.
O sistema de plugins resolve isso com um dump em runtime. O plugin entrega um master file (um por área lógica) sob seu source resources/lang/en/; no install, o host copia esse master para storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ para todo locale que o host suporta. As cópias dumped são o que trans() lê em runtime; a UI admin Languages do host edita as cópias dumped; o source do plugin permanece intocado. Re-instalar o plugin re-roda o dump, pegando qualquer key nova que o autor do plugin adicionou — sem sobrescrever traduções de locale que o admin editou nesse meio-tempo.
O fluxo de cinco passos
Esse é o caminho verificado do source do seu plugin até uma string renderizada em produção:
- O método
register() do plugin chama Hook::add('add_translation_file', ...), contribuindo um descriptor por arquivo de tradução lógico (file path, locale folder, namespace prefix).
- Em toda request, o
AppServiceProvider::boot() do host chama Hook::collect('add_translation_file') e itera as contribuições, chamando $this->loadTranslationsFrom() contra cada uma.
- Em
Plugin::register() (chamado automaticamente no fim de plugin:init e em todo upload bem-sucedido), o host chama Language::dump().
Language::dump() lê cada descriptor registrado e copia o master file para storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — uma vez por locale que o host suporta.
- Admins editam os runtime files dumped pela UI admin Languages. Chamadas a
trans() nas views Blade do plugin leem esses dump-clones editados, nunca o master de source do plugin.
Registrando com add_translation_file
O service provider do esqueleto mostra o registro canônico. Cada entrada é uma única contribuição REGISTRY:
// In ServiceProvider::register() ← MUST be register, not boot
Hook::add('add_translation_file', function () {
return [
'id' => '#acmecorp/loyalty_translation_file',
'plugin_name' => 'acmecorp/loyalty',
'file_title' => 'Translation for acmecorp/loyalty plugin',
'translation_folder' => storage_path('app/data/plugins/acmecorp/loyalty/lang/'),
'translation_prefix' => 'loyalty',
'file_name' => 'messages.php',
'master_translation_file' => realpath(__DIR__ . '/../resources/lang/en/messages.php'),
];
});
Cada key no descriptor é load-bearing:
| Key | O que o host faz com isso |
id | Identificador estável para a entrada — a UI admin Languages agrupa arquivos por id. |
plugin_name | A identidade {vendor}/{name} do plugin. Deixa a UI admin linkar entradas de tradução de volta ao plugin dono. |
file_title | Label human-readable renderizada acima da lista de strings editáveis na UI admin. |
translation_folder | Onde loadTranslationsFrom() registra o namespace. Deve apontar para o dumped runtime path sob storage/app/data/plugins/..., não para o source do plugin. |
translation_prefix | O prefixo de namespace que Blade alcança com trans('prefix::messages.foo'). Convencionalmente o segmento name do plugin para que permaneça único. |
file_name | Qual arquivo dentro da pasta locale essa entrada mapeia. Plugins com múltiplas superfícies de tradução registram uma entrada por arquivo. |
master_translation_file | Path absoluto para o master file source-controlled. Language::dump() lê daqui; os dump clones são escritos em translation_folder. |
Por que register(), não boot()
O AppServiceProvider::boot() do host chama Hook::collect('add_translation_file') na sua própria fase de boot. Laravel roda o register() de todo service provider primeiro, depois o boot() de todo provider — então quando o boot() de qualquer plugin roda, o loop collect do host já terminou. Um plugin que registra sua entrada add_translation_file em boot() contribui depois que o host parou de olhar, e a entrada nunca é pega. O sintoma visível é que trans('loyalty::messages.intro') retorna a key literal — sem tradução, sem fallback.
Esse é o único hook relacionado a tradução que vai em register(). Os hooks de lifecycle (activate_plugin_*, delete_plugin_*), routes, views, e todo outro hook permanecem em boot().
A armadilha do double-load
O instinto é que registrar por um hook mais também chamar o $this->loadTranslationsFrom() padrão do Laravel em boot() seria belt-and-suspenders. Não é — é um override silencioso.
O loop collect do host roda primeiro e aponta o namespace do plugin para o dumped runtime folder sob storage/app/data/plugins/.... O boot() do plugin roda depois do do host, e outra chamada loadTranslationsFrom() do plugin re-aponta o namespace para qualquer path que o plugin passou — tipicamente o folder source resources/lang/. Last call wins, então runtime termina lendo o master file de source diretamente.
O sintoma visível é que edits do admin na UI Languages param de ter efeito em runtime. Os dump clones viram arquivos zumbis: presentes no disco, editados por admins, mas nunca lidos porque o namespace hint aponta para outro lugar. Essa é a armadilha que o SOURCE_OF_TRUTH chama por nome.
Use apenas o hook add_translation_file. Não chame também $this->loadTranslationsFrom() do boot() do seu plugin. A única exceção é quando você precisa de um path de lookup não-namespaced (o plugin acelle/ai faz isso para que keys legadas trans('refactor/ai_chatbox.foo') continuem funcionando sem um prefixo de namespace) — e mesmo então, aponte-o para o source resources/lang/ do plugin apenas para fallback, não para o dump path.
Master file vs runtime files — dois paths para lembrar
| Path | O que é |
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php |
Master file. Vive na árvore de source do plugin. Você edita isso quando adiciona keys novas ou entrega cópia nova em inglês. git commit rastreia. Language::dump() lê daqui. |
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php |
Runtime file. Um por locale. Gerado por Language::dump() no install do plugin e em php artisan translation:upgrade. A UI admin Languages do host edita esses; trans() lê desses. Não commitado no source control — a cópia em inglês do autor do plugin vive no master, as cópias de locale vivem em cada install separadamente. |
Quando você entrega um update de plugin que adiciona uma key nova, você edita o arquivo master no source. Quando o build novo é deployado num install de produção, um admin roda php artisan translation:upgrade (ou a próxima chamada Plugin::register() faz automaticamente) e a key nova aparece no runtime file de todo locale com o valor em inglês como tradução inicial. Valores traduzidos existentes para keys que já existiam são preservados.
Dividindo em múltiplos arquivos de tradução
Um plugin pequeno com uma área lógica (settings, dashboard) está bem com um único master messages.php. Plugins maiores se beneficiam de dividir — cada arquivo vira uma entrada editável separadamente na UI admin Languages, e tradutores concorrentes podem trabalhar em arquivos diferentes sem conflitar. O padrão é uma chamada Hook::add('add_translation_file', ...) por arquivo.
O exemplo canônico é acelle/ai em storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197. O plugin registra nove arquivos de tradução separados, um por superfície:
$aiLangFiles = [
'ai_rewrite',
'ai_chatbox',
'ai_chatbox_prompts',
'ai_chatbox_wait',
'ai_subject_ab',
'ai_settings',
'admin_ai_usage',
'admin_ai_audit',
'admin_ai_permissions',
];
foreach ($aiLangFiles as $file) {
Hook::add('add_translation_file', function () use ($file) {
return [
'id' => "acelle_ai_{$file}",
'plugin_name' => 'acelle/ai',
'file_title' => 'AI — ' . ucfirst(str_replace('_', ' ', $file)),
'translation_folder' => __DIR__ . '/../resources/lang',
'file_name' => "refactor/{$file}.php",
'master_translation_file' => __DIR__ . "/../resources/lang/default/refactor/{$file}.php",
];
});
}
A divisão deixa o tradutor de support trabalhar na cópia do chatbox sem tocar nos labels do admin audit log — e deixa a UI admin entregar uma página de edit por arquivo que cabe numa tela em vez de um scroll de 1.000 linhas.
A convenção de dezoito locales
AcelleMail entrega traduções para dezoito locales: inglês, vietnamita, russo, coreano, japonês, chinês, alemão, francês, espanhol, português, italiano, holandês, polonês, sueco, ucraniano, turco, árabe, hindi. Uma checagem dentro de storage/app/data/plugins/acelle/ai/lang/ confirma o padrão: dezessete folders de locale ficam ao lado do source en, cada um com o conjunto completo de arquivos dump-cloned.
O trabalho do autor do plugin é entregar um master file em inglês apenas. Language::dump() cria os dezessete folders de locale não-inglês copiando o master inglês em cada um — toda key começa com o valor em inglês, e a UI admin Languages do host fornece o workflow para traduzi-las. Não há requisito de entregar locales pré-traduzidos no source do seu plugin. Fazer isso é bom quando você tem drafts machine-translated para seedar a UI admin, mas não é a norma — a maioria dos plugins entrega só em inglês e deixa o install traduzir.
Usando trans() nas views do seu plugin
A sintaxe Blade casa com qualquer translation_prefix que você registrou. Para o 'translation_prefix' => 'loyalty' do esqueleto:
{{ trans('loyalty::messages.intro') }}
{{ trans('loyalty::messages.welcome', ['name' => $customer->display_name]) }}
Os próprios controllers e services do seu plugin podem usar __() com o mesmo prefixo de namespace:
$message = __('loyalty::messages.points_awarded', ['count' => $points]);
Quando a key registrada não pode ser resolvida (typo, key faltando, ou o hook add_translation_file rodou em boot() em vez de register()), Laravel retorna a key literal como a string renderizada. Ver loyalty::messages.intro na página é o sintoma canônico de "as traduções não conectaram".
translation:upgrade — re-sincronizando depois de edits de master-file
Depois de editar o master file no source do plugin — adicionando uma key nova, corrigindo um typo na cópia em inglês — o autor do plugin precisa que os runtime files peguem a mudança. Duas maneiras:
- Re-instale o plugin.
Plugin::register() chama Language::dump() como um dos seus cinco passos. O dump preserva qualquer key que o admin já traduziu e adiciona keys novas com o valor do master inglês como tradução inicial.
- Rode o comando artisan diretamente:
php artisan translation:upgrade. Mesmo efeito, sem re-install do plugin necessário. Útil em desenvolvimento quando você está iterando na cópia do master-file.
Qualquer caminho é non-destructive — traduções admin-editadas sobrevivem. O comportamento é "merge keys novas do master no runtime, deixa valores runtime existentes em paz". Uma key nova em inglês aparece no runtime file de todo locale com o valor em inglês, pronta para o admin traduzir.
Cinco anti-padrões
1. Registrando add_translation_file em boot()
O loop collect do host já rodou antes do seu boot(). O hook dispara com sucesso mas nunca é pego. Fix: só registro de arquivo de tradução vai em register(); tudo mais permanece em boot().
2. Chamando $this->loadTranslationsFrom() junto com o hook
Re-aponta o namespace para seu folder de source, matando os dump-clones em runtime. Edits da UI admin Languages viram invisíveis. Fix: use só o hook; se um path de fallback não-namespaced é genuinamente necessário (raro — veja o caso acelle/ai), aponte-o explicitamente para o source do plugin sem sobrescrever o namespace hint.
3. Apontando translation_folder para o source do plugin
Mesmo efeito que a armadilha anterior, por uma rota diferente. O host registra seu namespace contra qualquer path que você passou — passe o source path e os dump-clones nunca são lidos. Fix: sempre seta translation_folder para o dumped runtime path sob storage/app/data/plugins/{vendor}/{name}/lang/.
4. Editando os arquivos dump-clone no repo de source do seu plugin
Erro fácil quando você está pegando "deixa eu só traduzir essa string". Os dump-clones são install-specific — eles vivem em storage/app/data/, que é gitignored em todo install AcelleMail. Editá-los no source não tem efeito; o próximo install re-roda dump() do seu master de source e sobrescreve o que você colocou no clone path. Fix: entregue masters de locale pré-traduzidos sob resources/lang/{locale}/ no source se você quer pré-tradução; dump() vai copiar de en apenas quando não houver master locale-specific para copiar.
5. trans('messages.foo') simples sem o prefixo de namespace
Laravel resolve keys sem namespace contra os lang folders do host, que não contêm as strings do seu plugin. Retorna a key literal. Fix: sempre prefixe com o translation_prefix que você registrou: trans('loyalty::messages.foo').
Para onde ir em seguida
Traduções fecham o loop de "qualidade" no lado da persistência — isolamento de schema, indireção em runtime, editabilidade pelo admin todos no lugar. As próximas duas páginas cobrem o resto da história de runtime do plugin: Ciclo de vida do plugin percorre os quatro estados (register → activate → disable → delete) no nível de método do model, e Testes cobre o wiring de phpunit.xml que mantém plugins em CI em todo build do host.