Archivo maestro. Volcado de runtime. Editable por el admin. Sin bifurcar el código fuente del plugin.

La copia en inglés de un plugin vive en su árbol de código fuente. La copia traducida vive en storage/app/data/plugins/{vendor}/{name}/lang/{locale}/: generada al instalar, editable a través de la UI de Idiomas del host y nunca escrita de vuelta a los archivos fuente del plugin. Esta página cubre toda la indirección: el hook REGISTRY add_translation_file, dónde van los clones volcados, la trampa que rompe la UI de Idiomas y la convención de dieciocho locales de acelle/ai.

Por qué el flujo es indirecto

Un autor de plugin escribe en inglés (y opcionalmente unas pocas traducciones primarias) en el árbol de código fuente. Los admins de producción quieren editar las cadenas en su instalación en ejecución (arreglar una errata, suavizar una etiqueta, traducir un locale extra) sin abrir nunca el código fuente del plugin. Ambas audiencias necesitan trabajar con el mismo conjunto de claves, pero no pueden compartir el mismo archivo: editar el código fuente en una instancia de producción se borra en la siguiente actualización del plugin, y editar una copia desplegada que refleja el código fuente significa que el archivo bajo control de versiones nunca ve el arreglo.

El sistema de plugins resuelve esto con un volcado de runtime. El plugin entrega un archivo maestro (uno por área lógica) bajo su resources/lang/en/ fuente; al instalar, el host copia ese maestro a storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ para cada locale que el host admita. Las copias volcadas son lo que trans() lee en runtime; la UI de Idiomas de administración del host edita las copias volcadas; el código fuente del plugin queda intacto. Reinstalar el plugin vuelve a ejecutar el volcado, recogiendo cualquier clave nueva que el autor del plugin haya añadido, sin sobrescribir las traducciones de locale que el admin haya editado mientras tanto.

El flujo de cinco pasos

Esta es la ruta verificada desde el código fuente de su plugin hasta una cadena renderizada en producción:

  1. El método register() del plugin llama a Hook::add('add_translation_file', ...), aportando un descriptor por archivo de traducción lógico (ruta del archivo, carpeta de locale, prefijo de namespace).
  2. En cada petición, el AppServiceProvider::boot() del host llama a Hook::collect('add_translation_file') e itera las contribuciones, llamando a $this->loadTranslationsFrom() contra cada una.
  3. En Plugin::register() (llamado automáticamente al final de plugin:init y en cada subida exitosa), el host llama a Language::dump().
  4. Language::dump() lee cada descriptor registrado y copia el archivo maestro a storage/app/data/plugins/{vendor}/{name}/lang/{locale}/: una vez por cada locale que el host admita.
  5. Los admins editan los archivos de runtime volcados a través de la UI de administración de Idiomas. Las llamadas trans() en las vistas Blade del plugin leen esos clones volcados editados, nunca el maestro del código fuente.

Registrar con add_translation_file

El service provider del esqueleto muestra el registro canónico. Cada entrada es una única contribución 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 clave del descriptor es relevante:

ClaveQué hace el host con ella
idIdentificador estable de la entrada: la UI de administración de Idiomas agrupa los archivos por id.
plugin_nameLa identidad del plugin {vendor}/{name}. Permite a la UI de administración enlazar las entradas de traducción de vuelta al plugin propietario.
file_titleEtiqueta legible para humanos que se renderiza sobre la lista editable de cadenas en la UI de administración.
translation_folderDonde loadTranslationsFrom() registra el namespace. Debe apuntar a la ruta de runtime volcada bajo storage/app/data/plugins/..., no al código fuente del plugin.
translation_prefixEl prefijo de namespace que Blade alcanza con trans('prefix::messages.foo'). Convencionalmente el segmento name del plugin para que se mantenga único.
file_nameA qué archivo dentro de la carpeta del locale mapea esta entrada. Los plugins con varias superficies de traducción registran una entrada por archivo.
master_translation_fileRuta absoluta al archivo maestro bajo control de versiones. Language::dump() lee desde aquí; los clones del volcado se escriben en translation_folder.

Por qué register(), no boot()

El AppServiceProvider::boot() del host llama a Hook::collect('add_translation_file') en su propia fase de boot. Laravel ejecuta primero el register() de cada service provider y luego el boot() de cada uno: así que para cuando se ejecuta el boot() de cualquier plugin, el bucle de collect del host ya ha terminado. Un plugin que registra su entrada add_translation_file en boot() aporta después de que el host haya dejado de mirar, y la entrada nunca se recoge. El síntoma visible es que trans('loyalty::messages.intro') devuelve la clave literal: sin traducción, sin fallback.

Este es el único hook relacionado con las traducciones que va en register(). Los hooks del ciclo de vida (activate_plugin_*, delete_plugin_*), las rutas, las vistas y todos los demás hooks se quedan en boot().

La trampa de la doble carga

El instinto es que registrar a través de un hook y además llamar al $this->loadTranslationsFrom() estándar de Laravel en boot() sería un cinturón-y-tirantes. No lo es: es una sobrescritura silenciosa.

El bucle de collect del host se ejecuta primero y apunta el namespace del plugin a la carpeta de runtime volcada bajo storage/app/data/plugins/.... El boot() del plugin se ejecuta después del del host, y otra llamada a loadTranslationsFrom() desde el plugin vuelve a apuntar el namespace a la ruta que el plugin pasó, normalmente la carpeta resources/lang/ del código fuente. Gana la última llamada, así que el runtime acaba leyendo directamente el archivo maestro del código fuente.

El síntoma visible es que las ediciones del admin en la UI de Idiomas dejan de surtir efecto en runtime. Los clones volcados se vuelven archivos zombis: presentes en disco, editados por los admins, pero nunca leídos porque la indicación de namespace apunta a otro sitio. Esta es la trampa que el SOURCE_OF_TRUTH señala por su nombre.

Use solo el hook add_translation_file. No llame además a $this->loadTranslationsFrom() desde el boot() de su plugin. La única excepción es cuando necesita una ruta de búsqueda sin namespace (el plugin acelle/ai lo hace para que las claves legacy trans('refactor/ai_chatbox.foo') sigan funcionando sin prefijo de namespace), e incluso entonces apunte al resources/lang/ del código fuente del plugin solo como fallback, no a la ruta del volcado.

Archivo maestro vs. archivos de runtime — dos rutas a recordar

RutaQué es
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php Archivo maestro. Vive en el árbol de código fuente del plugin. Lo edita cuando añade claves nuevas o entrega nueva copia en inglés. git commit lo rastrea. Language::dump() lee desde aquí.
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php Archivo de runtime. Uno por locale. Generado por Language::dump() al instalar el plugin y al ejecutar php artisan translation:upgrade. La UI de administración de Idiomas del host edita estos; trans() lee de estos. No se commitea al control de versiones: la copia en inglés del autor del plugin vive en el maestro, las copias por locale viven en cada instalación por separado.

Cuando entrega una actualización de plugin que añade una clave nueva, edita el archivo maestro en el código fuente. Cuando la nueva build se despliega a una instalación de producción, un admin ejecuta php artisan translation:upgrade (o la siguiente llamada a Plugin::register() lo hace automáticamente) y la clave nueva aparece en el archivo de runtime de cada locale con el valor inglés como su traducción inicial. Los valores traducidos existentes para claves que ya existían se preservan.

Dividir en varios archivos de traducción

Un plugin pequeño con un área lógica (settings, dashboard) está bien con un único messages.php maestro. Los plugins más grandes se benefician de dividir: cada archivo se convierte en una entrada editable por separado en la UI de administración de Idiomas, y varios traductores pueden trabajar en archivos distintos sin conflicto. El patrón es una llamada Hook::add('add_translation_file', ...) por archivo.

El ejemplo canónico es acelle/ai en storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197. El plugin registra nueve archivos de traducción separados, uno por superficie:

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

La división permite al traductor de soporte trabajar sobre la copia del chatbox sin tocar las etiquetas del registro de auditoría de administración, y permite a la UI de administración mostrar una página de edición por archivo que cabe en una pantalla, en lugar de un scroll de mil líneas.

La convención de dieciocho locales

AcelleMail entrega traducciones para dieciocho locales: inglés, vietnamita, ruso, coreano, japonés, chino, alemán, francés, español, portugués, italiano, neerlandés, polaco, sueco, ucraniano, turco, árabe e hindi. Una comprobación dentro de storage/app/data/plugins/acelle/ai/lang/ confirma el patrón: diecisiete carpetas de locale junto a la en del código fuente, cada una con el conjunto completo de archivos volcados.

El trabajo del autor del plugin es entregar un archivo maestro en inglés únicamente. Language::dump() crea las diecisiete carpetas de locale no ingleses copiando el maestro inglés en cada una: cada clave empieza con el valor inglés, y la UI de administración de Idiomas del host ofrece el flujo para traducirlas. No hay requisito de entregar locales pre-traducidos en el código fuente del plugin. Hacerlo está bien cuando tiene borradores traducidos por máquina para sembrar la UI de administración, pero no es la norma: la mayoría de los plugins se entregan solo en inglés y dejan que la instalación los traduzca.

Usar trans() en las vistas del plugin

La sintaxis Blade coincide con el translation_prefix que haya registrado. Para el 'translation_prefix' => 'loyalty' del esqueleto:

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


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

Los controladores y servicios propios de su plugin pueden usar __() con el mismo prefijo de namespace:

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

Cuando la clave registrada no se puede resolver (errata, clave ausente, o el hook add_translation_file se ejecutó en boot() en lugar de register()), Laravel devuelve la clave literal como cadena renderizada. Ver loyalty::messages.intro en la página es el síntoma canónico de «las traducciones no se conectaron».

translation:upgrade — resincronizar tras ediciones del archivo maestro

Tras editar el archivo maestro en el código fuente del plugin (añadir una clave nueva, arreglar una errata en la copia inglesa), el autor del plugin necesita que los archivos de runtime recojan el cambio. Dos formas:

  1. Reinstalar el plugin. Plugin::register() llama a Language::dump() como uno de sus cinco pasos. El volcado preserva las claves que el admin ya ha traducido y añade las claves nuevas con el valor del maestro inglés como traducción inicial.
  2. Ejecutar el comando artisan directamente: php artisan translation:upgrade. Mismo efecto, sin necesidad de reinstalar el plugin. Útil en desarrollo cuando está iterando sobre la copia del archivo maestro.

Cualquiera de las dos vías es no destructiva: las traducciones editadas por el admin sobreviven. El comportamiento es «fusionar claves nuevas desde el maestro al runtime, dejando intactos los valores de runtime existentes». Una clave inglesa nueva aparece en el archivo de runtime de cada locale con el valor inglés, lista para que el admin la traduzca.

Cinco antipatrones

1. Registrar add_translation_file en boot()

El bucle de collect del host ya se ejecutó antes que su boot(). El hook se dispara con éxito pero nunca se recoge. Solución: solo el registro de archivos de traducción va en register(); todo lo demás se queda en boot().

2. Llamar a $this->loadTranslationsFrom() junto con el hook

Vuelve a apuntar el namespace a su carpeta de código fuente, matando los clones del volcado en runtime. Las ediciones desde la UI de Idiomas de administración se vuelven invisibles. Solución: use solo el hook; si genuinamente se necesita una ruta de fallback sin namespace (raro: vea el caso de acelle/ai), apúntela explícitamente al código fuente del plugin sin sobrescribir la indicación del namespace.

3. Apuntar translation_folder al código fuente del plugin

Mismo efecto que la trampa anterior, por una vía distinta. El host registra su namespace contra la ruta que pase: pase la ruta del código fuente y los clones del volcado nunca se leen. Solución: ponga siempre translation_folder a la ruta de runtime volcada bajo storage/app/data/plugins/{vendor}/{name}/lang/.

4. Editar los archivos clones del volcado en el repo del código fuente del plugin

Error fácil al lanzarse a «déjame solo traducir esta cadena». Los clones del volcado son específicos de la instalación: viven en storage/app/data/, que está en gitignore en cada instalación de AcelleMail. Editarlos en el código fuente no surte efecto; la siguiente instalación vuelve a ejecutar dump() desde su maestro fuente y sobrescribe lo que haya puesto en la ruta del clon. Solución: entregue maestros de locale pre-traducidos bajo resources/lang/{locale}/ en el código fuente si quiere pre-traducción; dump() copiará desde en solo cuando no haya un maestro específico de locale del que copiar.

5. trans('messages.foo') sin el prefijo de namespace

Laravel resuelve las claves sin namespace contra las carpetas de idioma del host, que no contienen las cadenas de su plugin. Devuelve la clave literal. Solución: prefije siempre con el translation_prefix que haya registrado: trans('loyalty::messages.foo').

A dónde ir después

Las traducciones cierran el bucle de «calidad» en el lado de persistencia: aislamiento de esquema, indirección en runtime y editabilidad por el admin, todo en su sitio. Las dos páginas siguientes cubren el resto de la historia de runtime del plugin: Ciclo de vida del plugin recorre los cuatro estados (register → activate → disable → delete) a nivel de método de modelo, y Testing cubre la conexión con phpunit.xml que mantiene los plugins en CI en cada build del host.