Fichier maître. Dump runtime. Modifiable par l'administrateur. Sans forker la source du plugin.

La copie anglaise d'un plugin réside dans son arborescence source. La copie traduite réside dans storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — générée à l'installation, modifiable via l'interface Languages de l'hôte, et jamais réécrite vers les fichiers source du plugin. Cette page couvre l'indirection complète : le hook REGISTRY add_translation_file, l'emplacement des clones dumpés, le piège qui casse l'interface Languages et la convention des dix-huit locales d'acelle/ai.

Pourquoi le flux est indirect

L'auteur d'un plugin écrit l'anglais (et éventuellement quelques traductions primaires) dans l'arborescence source. Les administrateurs en production veulent modifier les chaînes sur leur installation en cours d'exécution — corriger une coquille, adoucir un libellé, traduire une locale supplémentaire — sans jamais ouvrir le code source du plugin. Les deux publics doivent travailler sur le même jeu de clés, mais ils ne peuvent pas partager le même fichier : modifier la source sur une instance de production sera écrasé à la prochaine mise à jour du plugin, et modifier une copie déployée qui reflète la source signifie que le fichier sous contrôle de source ne verra jamais le correctif.

Le système de plugins résout cela avec un dump runtime. Le plugin livre un fichier maître (un par zone logique) sous sa source resources/lang/en/ ; à l'installation, l'hôte copie ce maître dans storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ pour chaque locale prise en charge par l'hôte. Les copies dumpées sont ce que trans() lit à l'exécution ; l'interface Languages de l'hôte modifie les copies dumpées ; la source du plugin reste intacte. Réinstaller le plugin relance le dump, récupérant les nouvelles clés ajoutées par l'auteur — sans écraser les traductions de locale que l'administrateur a modifiées entre-temps.

Le flux en cinq étapes

Voici le chemin vérifié depuis la source de votre plugin jusqu'à une chaîne rendue en production :

  1. La méthode register() du plugin appelle Hook::add('add_translation_file', ...), contribuant un descripteur par fichier de traduction logique (chemin du fichier, dossier de locale, préfixe de namespace).
  2. À chaque requête, le AppServiceProvider::boot() de l'hôte appelle Hook::collect('add_translation_file') et itère les contributions, en appelant $this->loadTranslationsFrom() sur chacune.
  3. Lors de Plugin::register() (appelé automatiquement à la fin de plugin:init et à chaque upload réussi), l'hôte appelle Language::dump().
  4. Language::dump() lit chaque descripteur enregistré et copie le fichier maître dans storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — une fois par locale prise en charge par l'hôte.
  5. Les administrateurs modifient les fichiers runtime dumpés via l'interface Languages. Les appels trans() dans les vues Blade du plugin lisent ces clones de dump modifiés, jamais le maître source du plugin.

Enregistrement via add_translation_file

Le service provider du squelette montre l'enregistrement canonique. Chaque entrée est une contribution REGISTRY unique :

// 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'),
    ];
});

Chaque clé du descripteur a un rôle déterminant :

CléCe que l'hôte en fait
idIdentifiant stable de l'entrée — l'interface Languages groupe les fichiers par id.
plugin_nameL'identité du plugin {vendor}/{name}. Permet à l'interface admin de relier les entrées de traduction au plugin propriétaire.
file_titleLibellé lisible par un humain rendu au-dessus de la liste de chaînes éditables dans l'interface admin.
translation_folderLà où loadTranslationsFrom() enregistre le namespace. Doit pointer vers le chemin runtime dumpé sous storage/app/data/plugins/..., et non vers la source du plugin.
translation_prefixLe préfixe de namespace que Blade atteint avec trans('prefix::messages.foo'). Par convention, le segment name du plugin pour rester unique.
file_nameÀ quel fichier dans le dossier de locale cette entrée est mappée. Les plugins ayant plusieurs surfaces de traduction enregistrent une entrée par fichier.
master_translation_fileChemin absolu vers le fichier maître sous contrôle de source. Language::dump() lit depuis ici ; les clones de dump sont écrits dans translation_folder.

Pourquoi register(), pas boot()

Le AppServiceProvider::boot() de l'hôte appelle Hook::collect('add_translation_file') dans sa propre phase de boot. Laravel exécute d'abord le register() de chaque service provider, puis le boot() de chacun — de sorte qu'au moment où le boot() d'un plugin s'exécute, la boucle collect de l'hôte est déjà terminée. Un plugin qui enregistre son entrée add_translation_file dans boot() contribue après que l'hôte a cessé de regarder, et l'entrée n'est jamais récupérée. Le symptôme visible est que trans('loyalty::messages.intro') retourne la clé littérale — pas de traduction, pas de fallback.

C'est le seul hook lié à la traduction qui va dans register(). Les hooks de cycle de vie (activate_plugin_*, delete_plugin_*), les routes, les vues et tous les autres hooks restent dans boot().

Le piège du double chargement

L'instinct est qu'enregistrer via un hook puis appeler aussi le $this->loadTranslationsFrom() standard de Laravel dans boot() serait ceinture et bretelles. Ça ne l'est pas — c'est un override silencieux.

La boucle collect de l'hôte s'exécute en premier et pointe le namespace du plugin vers le dossier runtime dumpé sous storage/app/data/plugins/.... Le boot() du plugin s'exécute après celui de l'hôte, et un autre appel à loadTranslationsFrom() depuis le plugin repointe le namespace vers le chemin que le plugin a passé — typiquement le dossier source resources/lang/. Le dernier appel l'emporte, et le runtime finit donc par lire directement le fichier maître source.

Le symptôme visible est que les éditions admin dans l'interface Languages cessent de prendre effet à l'exécution. Les clones dumpés deviennent des fichiers zombies : présents sur disque, modifiés par les administrateurs, mais jamais lus parce que l'indication de namespace pointe ailleurs. C'est le piège que le SOURCE_OF_TRUTH nomme explicitement.

Utilisez le hook add_translation_file uniquement. N'appelez pas en plus $this->loadTranslationsFrom() depuis le boot() de votre plugin. La seule exception est lorsque vous avez besoin d'un chemin de lookup sans namespace (le plugin acelle/ai le fait pour que les clés legacy trans('refactor/ai_chatbox.foo') continuent de fonctionner sans préfixe de namespace) — et même là, pointez-le vers le resources/lang/ source du plugin pour le fallback uniquement, pas vers le chemin du dump.

Fichier maître vs fichiers runtime — deux chemins à retenir

CheminDe quoi il s'agit
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php Fichier maître. Réside dans l'arborescence source du plugin. Vous le modifiez quand vous ajoutez de nouvelles clés ou livrez une nouvelle copie anglaise. git commit le suit. Language::dump() lit depuis ici.
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php Fichier runtime. Un par locale. Généré par Language::dump() à l'installation du plugin et lors de php artisan translation:upgrade. L'interface Languages de l'hôte les modifie ; trans() lit depuis ceux-ci. Pas commités dans le contrôle de source — la copie anglaise de l'auteur du plugin réside dans le maître, les copies de locale résident sur chaque installation séparément.

Lorsque vous livrez une mise à jour de plugin qui ajoute une nouvelle clé, vous modifiez le fichier maître dans la source. Quand le nouveau build est déployé sur une installation de production, un administrateur lance php artisan translation:upgrade (ou le prochain appel à Plugin::register() le fait automatiquement) et la nouvelle clé apparaît dans le fichier runtime de chaque locale avec la valeur anglaise comme traduction initiale. Les valeurs traduites existantes pour des clés déjà présentes sont préservées.

Découper en plusieurs fichiers de traduction

Un petit plugin avec une seule zone logique (settings, dashboard) se contente d'un unique maître messages.php. Les plugins plus volumineux gagnent à être découpés — chaque fichier devient une entrée modifiable séparément dans l'interface Languages, et les traducteurs peuvent travailler en parallèle sur des fichiers différents sans conflit. Le pattern est un appel Hook::add('add_translation_file', ...) par fichier.

L'exemple canonique est acelle/ai dans storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197. Le plugin enregistre neuf fichiers de traduction séparés, un par 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",
        ];
    });
}

Le découpage permet au traducteur support de travailler sur la copie chatbox sans toucher aux libellés du journal d'audit admin — et permet à l'interface admin de présenter une page d'édition par fichier qui tient sur un écran au lieu d'un scroll de 1 000 lignes.

La convention des dix-huit locales

AcelleMail livre des traductions pour dix-huit locales : anglais, vietnamien, russe, coréen, japonais, chinois, allemand, français, espagnol, portugais, italien, néerlandais, polonais, suédois, ukrainien, turc, arabe, hindi. Une vérification dans storage/app/data/plugins/acelle/ai/lang/ confirme le pattern : dix-sept dossiers de locales aux côtés du en source, chacun avec le jeu complet de fichiers clonés depuis le dump.

Le travail de l'auteur du plugin est de livrer un fichier maître en anglais uniquement. Language::dump() crée les dix-sept dossiers de locales non anglaises en copiant le maître anglais dans chacun — chaque clé démarre avec la valeur anglaise, et l'interface Languages de l'hôte fournit le workflow pour les traduire. Il n'y a aucune obligation de livrer des locales prétraduites dans la source de votre plugin. Le faire est acceptable quand vous avez des brouillons machine-traduits pour amorcer l'interface admin, mais ce n'est pas la norme — la plupart des plugins livrent en anglais uniquement et laissent l'installation traduire.

Utiliser trans() dans vos vues de plugin

La syntaxe Blade correspond au translation_prefix que vous avez enregistré. Pour le 'translation_prefix' => 'loyalty' du squelette :

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


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

Les contrôleurs et services de votre plugin peuvent utiliser __() avec le même préfixe de namespace :

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

Lorsque la clé enregistrée ne peut pas être résolue (coquille, clé manquante, ou hook add_translation_file exécuté dans boot() au lieu de register()), Laravel retourne la clé littérale comme chaîne rendue. Voir loyalty::messages.intro sur la page est le symptôme canonique « les traductions ne sont pas câblées ».

translation:upgrade — resynchroniser après édition du fichier maître

Après avoir modifié le fichier maître dans la source du plugin — ajout d'une nouvelle clé, correction d'une coquille dans la copie anglaise — l'auteur du plugin a besoin que les fichiers runtime récupèrent le changement. Deux façons :

  1. Réinstaller le plugin. Plugin::register() appelle Language::dump() comme l'une de ses cinq étapes. Le dump préserve toutes les clés que l'administrateur a déjà traduites et ajoute les nouvelles clés avec la valeur du maître anglais comme traduction initiale.
  2. Lancer la commande artisan directement : php artisan translation:upgrade. Même effet, sans réinstallation du plugin. Utile en développement quand vous itérez sur la copie du fichier maître.

Les deux chemins sont non destructifs — les traductions modifiées par l'administrateur survivent. Le comportement est « fusionner les nouvelles clés du maître dans le runtime, laisser les valeurs runtime existantes intactes ». Une nouvelle clé anglaise apparaît dans le fichier runtime de chaque locale avec la valeur anglaise, prête à être traduite par l'administrateur.

Cinq anti-patterns

1. Enregistrer add_translation_file dans boot()

La boucle collect de l'hôte s'est déjà exécutée avant votre boot(). Le hook se déclenche avec succès mais n'est jamais récupéré. Correctif : seul l'enregistrement de fichier de traduction va dans register() ; tout le reste reste dans boot().

2. Appeler $this->loadTranslationsFrom() en plus du hook

Repointe le namespace vers votre dossier source, tuant les clones de dump à l'exécution. Les éditions de l'interface Languages admin deviennent invisibles. Correctif : utilisez le hook uniquement ; si un chemin de fallback sans namespace est réellement nécessaire (rare — voir le cas acelle/ai), pointez-le explicitement vers la source du plugin sans écraser l'indication de namespace.

3. Pointer translation_folder vers la source du plugin

Même effet que le piège précédent, par une autre route. L'hôte enregistre votre namespace contre le chemin que vous passez — passez le chemin source et les clones de dump ne sont jamais lus. Correctif : définissez toujours translation_folder sur le chemin runtime dumpé sous storage/app/data/plugins/{vendor}/{name}/lang/.

4. Modifier les fichiers clones de dump dans le dépôt source de votre plugin

Erreur facile quand on se dit « laissez-moi juste traduire cette chaîne ». Les clones de dump sont spécifiques à l'installation — ils résident dans storage/app/data/, qui est gitignored sur chaque installation AcelleMail. Les modifier dans la source n'a aucun effet ; la prochaine installation relance dump() depuis votre maître source et écrase ce que vous avez mis dans le chemin clone. Correctif : livrez des maîtres de locale prétraduits sous resources/lang/{locale}/ dans la source si vous voulez de la prétraduction ; dump() copiera depuis en uniquement quand il n'y a pas de maître spécifique à la locale à copier.

5. trans('messages.foo') simple sans le préfixe de namespace

Laravel résout les clés sans namespace contre les dossiers lang de l'hôte, qui ne contiennent pas les chaînes de votre plugin. Retourne la clé littérale. Correctif : préfixez toujours avec le translation_prefix que vous avez enregistré : trans('loyalty::messages.foo').

Où aller ensuite

Les traductions ferment la boucle « qualité » côté persistance — isolation de schéma, indirection runtime, éditabilité admin, tout est en place. Les deux pages suivantes couvrent le reste du parcours runtime du plugin : Cycle de vie du plugin parcourt les quatre états (register → activate → disable → delete) au niveau des méthodes du modèle, et Tests couvre le câblage phpunit.xml qui maintient les plugins en CI sur chaque build de l'hôte.