Master file. Runtime dump. Admin-editable. Senza forkare il source del tuo plugin.

La copy inglese di un plugin vive nel suo source tree. La copy tradotta vive in storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — generata all'install, editabile tramite l'UI Languages dell'host e mai riscritta nei file source del plugin. Questa pagina copre la full indirection: l'hook REGISTRY add_translation_file, dove vanno i clone dumped, il trap che rompe l'UI Languages e la convenzione a diciotto locale da acelle/ai.

Perché il flow è indiretto

Un autore di plugin scrive in inglese (e opzionalmente qualche traduzione primaria) nel source tree. Gli admin di produzione vogliono editare le stringhe sul loro install in esecuzione — fixare un typo, addolcire una label, tradurre una locale extra — senza mai aprire il codice source del plugin. Entrambe le audience hanno bisogno di lavorare con lo stesso set di key, ma non possono condividere lo stesso file: editare il source su un'istanza di produzione viene spazzato via al prossimo upgrade del plugin, ed editare una copia deployata che mirrora il source significa che il file source-controlled non vede mai la fix.

Il sistema di plugin risolve questo con un runtime dump. Il plugin spedisce un master file (uno per area logica) sotto il suo source resources/lang/en/; all'install, l'host copia quel master in storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ per ogni locale supportata dall'host. Le copie dumped sono ciò che trans() legge a runtime; l'UI admin Languages dell'host edita le copie dumped; il source del plugin resta intoccato. Re-installare il plugin riesegue il dump, raccogliendo qualsiasi nuova key l'autore del plugin abbia aggiunto — senza sovrascrivere le traduzioni locale che l'admin ha editato nel frattempo.

Il flow a cinque step

Questo è il path verificato dal source del tuo plugin a una stringa renderizzata in produzione:

  1. Il metodo register() del plugin chiama Hook::add('add_translation_file', ...), contribuendo un descriptor per ogni file di translation logico (file path, locale folder, namespace prefix).
  2. A ogni request, l'AppServiceProvider::boot() dell'host chiama Hook::collect('add_translation_file') e itera i contributi, chiamando $this->loadTranslationsFrom() contro ciascuno.
  3. Su Plugin::register() (chiamato automaticamente alla fine di plugin:init e a ogni upload riuscito), l'host chiama Language::dump().
  4. Language::dump() legge ogni descriptor registrato e copia il master file in storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — una volta per ogni locale supportata dall'host.
  5. Gli admin editano i file runtime dumped tramite l'UI admin Languages. Le chiamate trans() nelle view Blade del plugin leggono quei dump-clone editati, mai il master source del plugin.

Registrazione con add_translation_file

Il service provider dello skeleton mostra la registrazione canonica. Ogni entry è un singolo contributo 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'),
    ];
});

Ogni key nel descriptor è load-bearing:

KeyCosa ne fa l'host
idIdentifier stabile per l'entry — l'UI admin Languages raggruppa i file per id.
plugin_nameL'identità plugin {vendor}/{name}. Lascia all'UI admin collegare le entry di translation al plugin proprietario.
file_titleLabel human-readable renderizzata sopra la lista di stringhe editabili nell'UI admin.
translation_folderDove loadTranslationsFrom() registra il namespace. Deve puntare al runtime path dumped sotto storage/app/data/plugins/..., non al source del plugin.
translation_prefixIl namespace prefix che Blade raggiunge con trans('prefix::messages.foo'). Convenzionalmente il segmento name del plugin così resta unico.
file_nameA quale file dentro la locale folder questa entry mappa. I plugin con più surface di translation registrano un'entry per file.
master_translation_filePath assoluto al master file source-controlled. Language::dump() legge da qui; i dump clone vengono scritti in translation_folder.

Perché register(), non boot()

L'AppServiceProvider::boot() dell'host chiama Hook::collect('add_translation_file') nella propria fase di boot. Laravel esegue prima register() di ogni service provider, poi boot() di ogni provider — quindi quando il boot() di qualsiasi plugin gira, il collect loop dell'host ha già finito. Un plugin che registra la sua entry add_translation_file in boot() contribuisce dopo che l'host ha smesso di guardare, e l'entry non viene mai raccolta. Il sintomo visibile è che trans('loyalty::messages.intro') ritorna la key literal — nessuna traduzione, nessun fallback.

Questo è l'unico hook translation-related che va in register(). I lifecycle hook (activate_plugin_*, delete_plugin_*), le route, le view e ogni altro hook restano in boot().

Il double-load trap

L'istinto è che registrare tramite un hook più chiamare anche il $this->loadTranslationsFrom() standard di Laravel in boot() sarebbe belt-and-suspenders. Non lo è — è un override silenzioso.

Il collect loop dell'host gira per primo e punta il namespace del plugin alla runtime folder dumped sotto storage/app/data/plugins/.... Il boot() del plugin gira dopo quello dell'host, e un'altra chiamata loadTranslationsFrom() dal plugin ri-punta il namespace a qualsiasi path il plugin abbia passato — tipicamente la cartella source resources/lang/. L'ultima chiamata vince, quindi runtime finisce per leggere direttamente il master file source.

Il sintomo visibile è che gli edit admin nell'UI Languages smettono di avere effetto a runtime. I clone dumped diventano file zombie: presenti su disco, editati dagli admin, ma mai letti perché l'hint del namespace punta altrove. Questo è il trap che la SOURCE_OF_TRUTH chiama esplicitamente per nome.

Usa solo l'hook add_translation_file. Non chiamare anche $this->loadTranslationsFrom() dal boot() del tuo plugin. L'unica eccezione è quando ti serve un lookup path non-namespaced (il plugin acelle/ai fa questo così le legacy key trans('refactor/ai_chatbox.foo') continuano a funzionare senza namespace prefix) — e anche allora, puntalo al resources/lang/ source del plugin solo per fallback, non al path di dump.

Master file vs runtime file — due path da ricordare

PathCos'è
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php Master file. Vive nel source tree del plugin. Lo editi quando aggiungi nuove key o spedisci nuova copy inglese. git commit lo traccia. Language::dump() legge da qui.
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php Runtime file. Uno per locale. Generato da Language::dump() all'install del plugin e con php artisan translation:upgrade. L'UI admin Languages dell'host edita questi; trans() legge da questi. Non committato in source control — la copy inglese dell'autore del plugin vive nel master, le copie locale vivono su ogni install separatamente.

Quando spedisci un update del plugin che aggiunge una nuova key, editi il file master in source. Quando la nuova build viene deployata su un install di produzione, un admin esegue php artisan translation:upgrade (o la prossima chiamata Plugin::register() lo fa automaticamente) e la nuova key affiora nel runtime file di ogni locale con il valore inglese come traduzione iniziale. I valori tradotti esistenti per key che già esistevano sono preservati.

Splittare in più file di translation

Un plugin piccolo con un'area logica (settings, dashboard) va bene con un singolo master messages.php. I plugin più grandi beneficiano dello split — ogni file diventa un'entry separatamente editabile nell'UI admin Languages, e translator concorrenti possono lavorare su file diversi senza conflitti. Il pattern è una chiamata Hook::add('add_translation_file', ...) per file.

L'esempio canonico è acelle/ai in storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197. Il plugin registra nove file di translation separati, uno per surface:

$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",
        ];
    });
}

Lo split lascia che il translator di support lavori sulla copy della chatbox senza toccare le label dell'admin audit log — e lascia che l'UI admin esponga una pagina di edit per-file che sta in uno schermo invece di uno scroll da 1.000 righe.

La convenzione a diciotto locale

AcelleMail spedisce traduzioni per diciotto locale: inglese, vietnamita, russo, coreano, giapponese, cinese, tedesco, francese, spagnolo, portoghese, italiano, olandese, polacco, svedese, ucraino, turco, arabo, hindi. Un check dentro storage/app/data/plugins/acelle/ai/lang/ conferma il pattern: diciassette locale folder stanno accanto al source en, ciascuna con il set completo di file dump-cloned.

Il lavoro dell'autore del plugin è spedire un master file solo in inglese. Language::dump() crea le diciassette locale folder non-inglese copiando il master inglese in ciascuna — ogni key parte col valore inglese, e l'UI admin Languages dell'host fornisce il workflow per tradurle. Non c'è il requisito di spedire locale pre-tradotte nel source del tuo plugin. Farlo va bene quando hai draft machine-translated per seedare l'UI admin, ma non è la norma — la maggior parte dei plugin spedisce solo inglese e lascia che l'install traduca.

Usare trans() nelle view del tuo plugin

La sintassi Blade corrisponde a qualsiasi translation_prefix tu abbia registrato. Per il 'translation_prefix' => 'loyalty' dello skeleton:

{{ trans('loyalty::messages.intro') }}


{{ trans('loyalty::messages.welcome', ['name' => $customer->display_name]) }}

I controller e i service del tuo plugin possono usare __() con lo stesso namespace prefix:

$message = __('loyalty::messages.points_awarded', ['count' => $points]);

Quando la key registrata non può essere risolta (typo, key mancante, o l'hook add_translation_file è girato in boot() invece di register()), Laravel ritorna la key literal come stringa renderizzata. Vedere loyalty::messages.intro sulla pagina è il sintomo canonico "le translation non si sono cablate".

translation:upgrade — re-sync dopo gli edit del master file

Dopo aver editato il master file nel source del plugin — aggiungendo una nuova key, fixando un typo nella copy inglese — l'autore del plugin ha bisogno che i runtime file raccolgano il cambiamento. Due modi:

  1. Reinstalla il plugin. Plugin::register() chiama Language::dump() come uno dei suoi cinque step. Il dump preserva qualsiasi key l'admin abbia già tradotto e aggiunge nuove key col valore master inglese come traduzione iniziale.
  2. Esegui il comando artisan direttamente: php artisan translation:upgrade. Stesso effetto, nessuna re-install del plugin necessaria. Utile in development quando stai iterando sulla copy del master file.

Entrambi i path sono non distruttivi — le traduzioni admin-edited sopravvivono. Il comportamento è "merge delle nuove key dal master nel runtime, lascia stare i valori runtime esistenti". Una nuova key inglese appare nel runtime file di ogni locale col valore inglese, pronta per l'admin per tradurla.

Cinque anti-pattern

1. Registrare add_translation_file in boot()

Il collect loop dell'host è già girato prima del tuo boot(). L'hook spara con successo ma non viene mai raccolto. Fix: solo la registrazione di file di translation va in register(); tutto il resto resta in boot().

2. Chiamare $this->loadTranslationsFrom() insieme all'hook

Ri-punta il namespace alla tua cartella source, uccidendo i dump-clone a runtime. Gli edit dall'UI admin Languages diventano invisibili. Fix: usa solo l'hook; se un fallback non-namespaced path è genuinamente necessario (raro — vedi il caso acelle/ai), puntalo esplicitamente al source del plugin senza sovrascrivere l'hint del namespace.

3. Puntare translation_folder al source del plugin

Stesso effetto del trap precedente, per una via diversa. L'host registra il tuo namespace contro qualsiasi path tu abbia passato — passa il source path e i dump-clone non vengono mai letti. Fix: imposta sempre translation_folder al runtime path dumped sotto storage/app/data/plugins/{vendor}/{name}/lang/.

4. Editare i file dump-clone nel repository source del tuo plugin

Errore facile quando vai a "lasciami tradurre questa singola stringa". I dump-clone sono install-specific — vivono in storage/app/data/, che è gitignored su ogni install di AcelleMail. Editarli in source non ha effetto; la prossima install riesegue dump() dal tuo master source e sovrascrive qualsiasi cosa tu abbia messo nel clone path. Fix: spedisci master locale pre-tradotti sotto resources/lang/{locale}/ in source se vuoi pre-translation; dump() copierà da en solo quando non c'è un master locale-specific da cui copiare.

5. trans('messages.foo') plain senza il namespace prefix

Laravel risolve le key senza namespace contro le lang folder dell'host, che non contengono le stringhe del tuo plugin. Ritorna la key literal. Fix: prefissa sempre con il translation_prefix che hai registrato: trans('loyalty::messages.foo').

Dove andare dopo

Le translation chiudono il loop "quality" sul lato persistence — isolamento dello schema, runtime indirection, admin editability tutto in piedi. Le prossime due pagine coprono il resto della runtime story del plugin: Plugin lifecycle percorre i quattro stati (register → activate → disable → delete) a livello model-method, e Testing copre il cablaggio phpunit.xml che tiene i plugin in CI su ogni host build.