Why the flow is indirect
A plugin author writes English (and optionally a few primary translations) in the source tree. Production admins want to edit the strings on their running install — fix a typo, soften a label, translate an extra locale — without ever opening the plugin's source code. Both audiences need to work with the same set of keys, but they cannot share the same file: editing the source on a production instance gets blown away on the next plugin upgrade, and editing a deployed copy that mirrors the source means the source-controlled file never sees the fix.
The plugin system resolves this with a runtime dump. The plugin ships a master file (one per logical area) under its source resources/lang/en/; on install, the host copies that master into storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ for every locale the host supports. The dumped copies are what trans() reads at runtime; the host's Languages admin UI edits the dumped copies; the plugin source stays untouched. Re-installing the plugin re-runs the dump, picking up any new keys the plugin author added — without overwriting locale translations that the admin has edited in the meantime.
The five-step flow
This is the verified path from your plugin source to a string rendered in production:
- The plugin's
register() method calls Hook::add('add_translation_file', ...), contributing one descriptor per logical translation file (file path, locale folder, namespace prefix).
- On every request, the host's
AppServiceProvider::boot() calls Hook::collect('add_translation_file') and iterates the contributions, calling $this->loadTranslationsFrom() against each one.
- On
Plugin::register() (called automatically at the end of plugin:init and on every successful upload), the host calls Language::dump().
Language::dump() reads each registered descriptor and copies the master file into storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — once per locale the host supports.
- Admins edit the dumped runtime files through the Languages admin UI.
trans() calls in plugin Blade views read those edited dump-clones, never the plugin source master.
Registering with add_translation_file
The skeleton's service provider shows the canonical registration. Each entry is a single REGISTRY contribution:
// 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'),
];
});
Each key in the descriptor is load-bearing:
| Key | What the host does with it |
id | Stable identifier for the entry — the Languages admin UI groups files by id. |
plugin_name | The {vendor}/{name} plugin identity. Lets the admin UI link translation entries back to the owning plugin. |
file_title | Human-readable label rendered above the editable string list in the admin UI. |
translation_folder | Where loadTranslationsFrom() registers the namespace. Must point at the dumped runtime path under storage/app/data/plugins/..., not at the plugin source. |
translation_prefix | The namespace prefix Blade reaches with trans('prefix::messages.foo'). Conventionally the plugin's name segment so it stays unique. |
file_name | Which file inside the locale folder this entry maps to. Plugins with multiple translation surfaces register one entry per file. |
master_translation_file | Absolute path to the source-controlled master file. Language::dump() reads from here; the dump clones are written into translation_folder. |
Why register(), not boot()
The host's AppServiceProvider::boot() calls Hook::collect('add_translation_file') in its own boot phase. Laravel runs every service provider's register() first, then every provider's boot() — so by the time any plugin's boot() runs, the host's collect loop has already finished. A plugin that registers its add_translation_file entry in boot() contributes after the host has stopped looking, and the entry never gets picked up. The visible symptom is that trans('loyalty::messages.intro') returns the literal key — no translation, no fallback.
This is the only translation-related hook that goes in register(). The lifecycle hooks (activate_plugin_*, delete_plugin_*), routes, views, and every other hook stay in boot().
The double-load trap
The instinct is that registering through a hook plus also calling Laravel's standard $this->loadTranslationsFrom() in boot() would be belt-and-suspenders. It is not — it is a silent override.
The host's collect loop runs first and points the plugin's namespace at the dumped runtime folder under storage/app/data/plugins/.... The plugin's boot() runs after the host's, and another loadTranslationsFrom() call from the plugin re-points the namespace at whatever path the plugin passed — typically the source resources/lang/ folder. Last call wins, so runtime ends up reading the source master file directly.
The visible symptom is that admin edits in the Languages UI stop taking effect at runtime. The dumped clones become zombie files: present on disk, edited by admins, but never read because the namespace hint points elsewhere. This is the trap the SOURCE_OF_TRUTH calls out by name.
Use the add_translation_file hook only. Do not also call $this->loadTranslationsFrom() from your plugin's boot(). The single exception is when you need a non-namespaced lookup path (the acelle/ai plugin does this so legacy trans('refactor/ai_chatbox.foo') keys keep working without a namespace prefix) — and even then, point it at the plugin's source resources/lang/ for fallback only, not at the dump path.
Master file vs runtime files — two paths to remember
| Path | What it is |
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php |
Master file. Lives in the plugin's source tree. You edit this when adding new keys or shipping new English copy. git commit tracks it. Language::dump() reads from here. |
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php |
Runtime file. One per locale. Generated by Language::dump() on plugin install and on php artisan translation:upgrade. The host's Languages admin UI edits these; trans() reads from these. Not committed to source control — the plugin author's English copy lives in the master, the locale copies live on each install separately. |
When you ship a plugin update that adds a new key, you edit the master file in source. When the new build deploys to a production install, an admin runs php artisan translation:upgrade (or the next Plugin::register() call does it automatically) and the new key surfaces in every locale's runtime file with the English value as its initial translation. Existing translated values for keys that already existed are preserved.
Splitting into multiple translation files
A small plugin with one logical area (settings, dashboard) is fine with a single master messages.php. Larger plugins benefit from splitting — each file becomes a separately editable entry in the Languages admin UI, and concurrent translators can work on different files without conflicting. The pattern is one Hook::add('add_translation_file', ...) call per file.
The canonical example is acelle/ai at storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197. The plugin registers nine separate translation files, one 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",
];
});
}
The split lets the support translator work on chatbox copy without touching the admin audit log labels — and lets the admin UI surface a per-file edit page that fits in one screen instead of a 1,000-line scroll.
The eighteen-locale convention
AcelleMail ships translations for eighteen locales: English, Vietnamese, Russian, Korean, Japanese, Chinese, German, French, Spanish, Portuguese, Italian, Dutch, Polish, Swedish, Ukrainian, Turkish, Arabic, Hindi. A check inside storage/app/data/plugins/acelle/ai/lang/ confirms the pattern: seventeen locale folders sit alongside the source en, each with the full set of dump-cloned files.
The plugin author's job is to ship a master file in English only. Language::dump() creates the seventeen non-English locale folders by copying the English master into each one — every key starts with the English value, and the host's Languages admin UI provides the workflow to translate them. There is no requirement to ship pre-translated locales in your plugin source. Doing so is fine when you have machine-translated drafts to seed the admin UI, but it is not the norm — most plugins ship English-only and let the install translate.
Using trans() in your plugin views
The Blade syntax matches whatever translation_prefix you registered. For the skeleton's 'translation_prefix' => 'loyalty':
{{ trans('loyalty::messages.intro') }}
{{ trans('loyalty::messages.welcome', ['name' => $customer->display_name]) }}
Your plugin's own controllers and services can use __() with the same namespace prefix:
$message = __('loyalty::messages.points_awarded', ['count' => $points]);
When the registered key cannot be resolved (typo, missing key, or the add_translation_file hook ran in boot() instead of register()), Laravel returns the literal key as the rendered string. Seeing loyalty::messages.intro on the page is the canonical "translations did not wire up" symptom.
translation:upgrade — re-syncing after master-file edits
After editing the master file in plugin source — adding a new key, fixing a typo in English copy — the plugin author needs the runtime files to pick up the change. Two ways:
- Re-install the plugin.
Plugin::register() calls Language::dump() as one of its five steps. The dump preserves any keys the admin has already translated and adds new keys with the English master value as their initial translation.
- Run the artisan command directly:
php artisan translation:upgrade. Same effect, no plugin re-install needed. Useful in development when you are iterating on master-file copy.
Either path is non-destructive — admin-edited translations survive. The behaviour is "merge new keys from master into runtime, leave existing runtime values alone". A new English key appears in every locale's runtime file with the English value, ready for the admin to translate.
Five anti-patterns
1. Registering add_translation_file in boot()
The host's collect loop already ran before your boot(). The hook fires successfully but never gets picked up. Fix: only translation-file registration goes in register(); everything else stays in boot().
2. Calling $this->loadTranslationsFrom() alongside the hook
Re-points the namespace at your source folder, killing the dump-clones at runtime. Admin Languages-UI edits become invisible. Fix: use the hook only; if a fallback non-namespaced path is genuinely needed (rare — see the acelle/ai case), point it explicitly at the plugin source without overwriting the namespace hint.
3. Pointing translation_folder at the plugin source
Same effect as the previous trap, by a different route. The host registers your namespace against whatever path you passed — pass the source path and the dump-clones never get read. Fix: always set translation_folder to the dumped runtime path under storage/app/data/plugins/{vendor}/{name}/lang/.
4. Editing the dump-clone files in your plugin source repo
Easy mistake when reaching for "let me just translate this one string". The dump-clones are install-specific — they live in storage/app/data/, which is gitignored on every Acelle install. Editing them in source has no effect; the next install re-runs dump() from your source master and overwrites whatever you put in the clone path. Fix: ship pre-translated locale masters under resources/lang/{locale}/ in source if you want pre-translation; dump() will copy from en only when there is no locale-specific master to copy from.
5. Plain trans('messages.foo') without the namespace prefix
Laravel resolves namespace-less keys against the host's lang folders, which do not contain your plugin's strings. Returns the literal key. Fix: always prefix with the translation_prefix you registered: trans('loyalty::messages.foo').
Where to go next
Translations close the "quality" loop on the persistence side — schema isolation, runtime indirection, admin editability all in place. The next two pages cover the rest of the plugin's runtime story: Plugin lifecycle walks the four states (register → activate → disable → delete) at the model-method level, and Testing covers the phpunit.xml wiring that keeps plugins in CI on every host build.