Warum der Flow indirekt ist
Ein Plugin-Autor schreibt im Quellcode Englisch (und optional einige primäre Übersetzungen). Produktions-Admins wollen die Strings auf ihrer laufenden Installation editieren — einen Tippfehler korrigieren, ein Label weicher formulieren, eine zusätzliche Sprache übersetzen — ohne je den Plugin-Quellcode zu öffnen. Beide Gruppen müssen mit demselben Key-Set arbeiten, können sich aber nicht dieselbe Datei teilen: Den Quellcode auf einer Produktionsinstanz zu editieren, wird beim nächsten Plugin-Upgrade weggespült, und eine deployte Kopie zu editieren, die den Quellcode spiegelt, bedeutet, dass die quellcode-verwaltete Datei den Fix nie sieht.
Das Plugin-System löst das per Runtime-Dump. Das Plugin liefert eine Master-Datei aus (eine pro logischem Bereich) unter seinem Quellcode-Pfad resources/lang/en/; bei der Installation kopiert der Host diese Master-Datei für jede vom Host unterstützte Sprache nach storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. Die geklonten Kopien sind das, was trans() zur Laufzeit liest; die Languages-Admin-UI des Hosts editiert die geklonten Kopien; der Plugin-Quellcode bleibt unangetastet. Eine Neuinstallation des Plugins führt den Dump erneut aus und greift neue Keys auf, die der Plugin-Autor hinzugefügt hat — ohne Locale-Übersetzungen zu überschreiben, die der Admin in der Zwischenzeit editiert hat.
Der Fünf-Schritte-Flow
Das ist der verifizierte Pfad von Ihrem Plugin-Quellcode zu einem in der Produktion gerenderten String:
- Die
register()-Methode des Plugins ruft Hook::add('add_translation_file', ...) auf und steuert einen Deskriptor pro logischer Übersetzungsdatei bei (Dateipfad, Locale-Ordner, Namespace-Prefix).
- Bei jedem Request ruft das
AppServiceProvider::boot() des Hosts Hook::collect('add_translation_file') auf, iteriert die Beiträge und ruft für jeden $this->loadTranslationsFrom() auf.
- Auf
Plugin::register() (automatisch am Ende von plugin:init und bei jedem erfolgreichen Upload aufgerufen) ruft der Host Language::dump() auf.
Language::dump() liest jeden registrierten Deskriptor und kopiert die Master-Datei für jede vom Host unterstützte Sprache nach storage/app/data/plugins/{vendor}/{name}/lang/{locale}/.
- Admins editieren die gedumpten Runtime-Dateien über die Languages-Admin-UI.
trans()-Aufrufe in Plugin-Blade-Views lesen diese editierten Dump-Klone — nie den Plugin-Quellcode-Master.
Registrierung mit add_translation_file
Der Service Provider des Skeletons zeigt die kanonische Registrierung. Jeder Eintrag ist ein einzelner REGISTRY-Beitrag:
// 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'),
];
});
Jeder Schlüssel im Deskriptor ist tragend:
| Schlüssel | Was der Host damit tut |
id | Stabiler Identifier für den Eintrag — die Languages-Admin-UI gruppiert Dateien nach id. |
plugin_name | Die {vendor}/{name}-Identität des Plugins. Lässt die Admin-UI Übersetzungseinträge mit dem besitzenden Plugin verknüpfen. |
file_title | Menschenlesbares Label, das in der Admin-UI über der editierbaren String-Liste gerendert wird. |
translation_folder | Wo loadTranslationsFrom() den Namespace registriert. Muss auf den gedumpten Runtime-Pfad unter storage/app/data/plugins/... zeigen — nicht auf den Plugin-Quellcode. |
translation_prefix | Der Namespace-Prefix, den Blade über trans('prefix::messages.foo') erreicht. Konventionell der name-Teil des Plugins, damit er eindeutig bleibt. |
file_name | Welche Datei im Locale-Ordner dieser Eintrag adressiert. Plugins mit mehreren Übersetzungsflächen registrieren einen Eintrag pro Datei. |
master_translation_file | Absoluter Pfad zur quellcode-verwalteten Master-Datei. Language::dump() liest von hier; die Dump-Klone werden in translation_folder geschrieben. |
Warum register(), nicht boot()
Das AppServiceProvider::boot() des Hosts ruft Hook::collect('add_translation_file') in seiner eigenen Boot-Phase auf. Laravel führt zuerst die register()-Methode jedes Service Providers aus, danach die boot()-Methode jedes Providers — sobald also das boot() eines Plugins läuft, hat die Collect-Schleife des Hosts bereits abgeschlossen. Ein Plugin, das seinen add_translation_file-Eintrag in boot() registriert, steuert ihn nach dem Ende der Sammelphase bei, und der Eintrag wird nie aufgegriffen. Das sichtbare Symptom: trans('loyalty::messages.intro') liefert den literalen Schlüssel zurück — keine Übersetzung, kein Fallback.
Das ist der einzige übersetzungsbezogene Hook, der in register() gehört. Die Lifecycle-Hooks (activate_plugin_*, delete_plugin_*), Routen, Views und jeder andere Hook bleiben in boot().
Die Double-Load-Falle
Der Reflex ist: per Hook zu registrieren und zusätzlich Laravels Standard-$this->loadTranslationsFrom() in boot() aufzurufen, sei Gürtel und Hosenträger. Ist es nicht — es ist ein stilles Override.
Die Collect-Schleife des Hosts läuft zuerst und zeigt den Namespace des Plugins auf den gedumpten Runtime-Ordner unter storage/app/data/plugins/.... Das boot() des Plugins läuft danach, und ein weiterer loadTranslationsFrom()-Aufruf aus dem Plugin zeigt den Namespace auf den vom Plugin übergebenen Pfad um — typischerweise den Quellcode-Ordner resources/lang/. Last call wins, also liest die Laufzeit am Ende direkt die Quellcode-Master-Datei.
Das sichtbare Symptom: Admin-Edits in der Languages-UI greifen zur Laufzeit nicht mehr. Die gedumpten Klone werden zu Zombie-Dateien — auf der Platte vorhanden, vom Admin editiert, aber nie gelesen, weil der Namespace-Hint woandershin zeigt. Das ist die Falle, die das SOURCE_OF_TRUTH-Dokument namentlich nennt.
Nutzen Sie ausschließlich den add_translation_file-Hook. Rufen Sie nicht zusätzlich $this->loadTranslationsFrom() aus dem boot() Ihres Plugins auf. Die einzige Ausnahme: Sie brauchen einen Lookup-Pfad ohne Namespace (das acelle/ai-Plugin macht das, damit Legacy-Keys wie trans('refactor/ai_chatbox.foo') ohne Namespace-Prefix weiter funktionieren) — und selbst dann zeigen Sie ihn nur auf das Quellcode-resources/lang/ des Plugins als Fallback, nicht auf den Dump-Pfad.
Master-Datei vs. Runtime-Dateien — zwei Pfade, die Sie kennen müssen
| Pfad | Was es ist |
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php |
Master-Datei. Liegt im Quellcode-Tree des Plugins. Sie editieren sie, wenn Sie neue Keys hinzufügen oder neue englische Copy ausliefern. git commit trackt sie. Language::dump() liest von hier. |
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php |
Runtime-Datei. Eine pro Sprache. Von Language::dump() bei der Plugin-Installation und bei php artisan translation:upgrade generiert. Die Languages-Admin-UI des Hosts editiert diese; trans() liest aus diesen. Nicht in die Versionskontrolle eingecheckt — die englische Copy des Plugin-Autors lebt im Master, die Locale-Kopien leben auf jeder Installation separat. |
Wenn Sie ein Plugin-Update ausliefern, das einen neuen Schlüssel hinzufügt, editieren Sie die Master-Datei im Quellcode. Wenn der neue Build auf eine Produktionsinstallation deployt wird, ruft ein Admin php artisan translation:upgrade auf (oder der nächste Plugin::register()-Aufruf erledigt das automatisch), und der neue Schlüssel taucht in der Runtime-Datei jeder Sprache mit dem englischen Wert als initialer Übersetzung auf. Bestehende übersetzte Werte für bereits existierende Schlüssel bleiben erhalten.
Auf mehrere Übersetzungsdateien aufteilen
Ein kleines Plugin mit einem logischen Bereich (Einstellungen, Dashboard) kommt mit einer einzigen Master-messages.php aus. Größere Plugins profitieren von einer Aufteilung — jede Datei wird in der Languages-Admin-UI ein separat editierbarer Eintrag, und parallel arbeitende Übersetzer können konfliktfrei an verschiedenen Dateien arbeiten. Das Pattern: ein Hook::add('add_translation_file', ...)-Aufruf pro Datei.
Das kanonische Beispiel ist acelle/ai in storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197. Das Plugin registriert neun separate Übersetzungsdateien, eine pro Oberfläche:
$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",
];
});
}
Die Aufteilung erlaubt es dem Support-Übersetzer, an der Chatbox-Copy zu arbeiten, ohne die Labels des Admin-Audit-Logs zu berühren — und lässt die Admin-UI eine Per-Datei-Edit-Seite anzeigen, die auf einen Bildschirm passt, statt eines 1.000-Zeilen-Scrolls.
Die Achtzehn-Locale-Konvention
AcelleMail liefert Übersetzungen für achtzehn Sprachen aus: Englisch, Vietnamesisch, Russisch, Koreanisch, Japanisch, Chinesisch, Deutsch, Französisch, Spanisch, Portugiesisch, Italienisch, Niederländisch, Polnisch, Schwedisch, Ukrainisch, Türkisch, Arabisch, Hindi. Ein Blick in storage/app/data/plugins/acelle/ai/lang/ bestätigt das Muster: siebzehn Locale-Ordner liegen neben dem Quell-en, jeder mit dem vollständigen Set an Dump-Klonen.
Die Aufgabe des Plugin-Autors ist es, eine Master-Datei nur in Englisch auszuliefern. Language::dump() erstellt die siebzehn nicht-englischen Locale-Ordner, indem es den englischen Master in jeden hineinkopiert — jeder Schlüssel startet mit dem englischen Wert, und die Languages-Admin-UI des Hosts liefert den Workflow zur Übersetzung. Es gibt keine Pflicht, in Ihrem Plugin-Quellcode vorübersetzte Sprachen mitzuliefern. Es ist in Ordnung, wenn Sie maschinell übersetzte Entwürfe als Seed für die Admin-UI haben, aber es ist nicht die Norm — die meisten Plugins liefern Englisch-only und lassen die Installation übersetzen.
trans() in Ihren Plugin-Views nutzen
Die Blade-Syntax richtet sich nach dem translation_prefix, den Sie registriert haben. Für das 'translation_prefix' => 'loyalty' des Skeletons:
{{ trans('loyalty::messages.intro') }}
{{ trans('loyalty::messages.welcome', ['name' => $customer->display_name]) }}
Die eigenen Controller und Services Ihres Plugins können __() mit demselben Namespace-Prefix verwenden:
$message = __('loyalty::messages.points_awarded', ['count' => $points]);
Wenn der registrierte Schlüssel nicht aufgelöst werden kann (Tippfehler, fehlender Schlüssel oder der add_translation_file-Hook lief in boot() statt in register()), liefert Laravel den literalen Schlüssel als gerenderten String zurück. loyalty::messages.intro auf der Seite zu sehen, ist das kanonische Symptom „Übersetzungen sind nicht verdrahtet".
translation:upgrade — Re-Sync nach Edits an der Master-Datei
Nachdem die Master-Datei im Plugin-Quellcode editiert wurde — neuer Schlüssel hinzugefügt, Tippfehler in englischer Copy korrigiert —, muss der Plugin-Autor dafür sorgen, dass die Runtime-Dateien die Änderung aufnehmen. Zwei Wege:
- Plugin neu installieren.
Plugin::register() ruft Language::dump() als einen seiner fünf Schritte auf. Der Dump bewahrt alle vom Admin bereits übersetzten Keys und ergänzt neue Keys mit dem englischen Master-Wert als initialer Übersetzung.
- Den Artisan-Befehl direkt ausführen:
php artisan translation:upgrade. Gleicher Effekt, ohne Plugin-Re-Install. Nützlich in der Entwicklung, wenn Sie an der Master-Datei iterieren.
Beide Wege sind zerstörungsfrei — vom Admin editierte Übersetzungen überleben. Das Verhalten ist „neue Keys vom Master ins Runtime mergen, bestehende Runtime-Werte unangetastet lassen". Ein neuer englischer Key taucht in der Runtime-Datei jeder Sprache mit dem englischen Wert auf, bereit für die Übersetzung durch den Admin.
Fünf Anti-Patterns
1. add_translation_file in boot() registrieren
Die Collect-Schleife des Hosts ist vor Ihrem boot() bereits gelaufen. Der Hook feuert erfolgreich, wird aber nie aufgegriffen. Fix: Nur die Registrierung von Übersetzungsdateien gehört in register(); alles andere bleibt in boot().
2. Zusätzlich zum Hook $this->loadTranslationsFrom() aufrufen
Zeigt den Namespace auf Ihren Quellcode-Ordner um und macht die Dump-Klone zur Laufzeit kaputt. Admin-Edits in der Languages-UI werden unsichtbar. Fix: Verwenden Sie ausschließlich den Hook; ist ein Fallback-Pfad ohne Namespace wirklich nötig (selten — siehe den acelle/ai-Fall), zeigen Sie ihn explizit auf den Plugin-Quellcode, ohne den Namespace-Hint zu überschreiben.
3. translation_folder auf den Plugin-Quellcode zeigen lassen
Gleiche Wirkung wie die vorige Falle, nur über einen anderen Weg. Der Host registriert Ihren Namespace gegen den Pfad, den Sie übergeben haben — übergeben Sie den Quellcode-Pfad, werden die Dump-Klone nie gelesen. Fix: Setzen Sie translation_folder immer auf den gedumpten Runtime-Pfad unter storage/app/data/plugins/{vendor}/{name}/lang/.
4. Die Dump-Klon-Dateien in Ihrem Plugin-Quellcode-Repo editieren
Leichter Fehler beim Reflex „lass mich nur kurz diesen einen String übersetzen". Die Dump-Klone sind installationsspezifisch — sie liegen in storage/app/data/, das auf jeder AcelleMail-Installation gitignored ist. Sie im Quellcode zu editieren hat keinen Effekt; die nächste Installation führt dump() erneut aus Ihrem Quell-Master aus und überschreibt, was Sie im Klon-Pfad abgelegt haben. Fix: Liefern Sie vorübersetzte Locale-Master unter resources/lang/{locale}/ im Quellcode aus, falls Sie eine Vorübersetzung wünschen; dump() kopiert nur aus en, wenn es keinen locale-spezifischen Master gibt.
5. Schlichtes trans('messages.foo') ohne Namespace-Prefix
Laravel löst Schlüssel ohne Namespace gegen die Lang-Ordner des Hosts auf, die Ihre Plugin-Strings nicht enthalten. Liefert den literalen Schlüssel zurück. Fix: Präfixen Sie immer mit dem registrierten translation_prefix: trans('loyalty::messages.foo').
Wie es weitergeht
Übersetzungen schließen die „Qualitäts"-Schleife auf der Persistenz-Seite — Schema-Isolation, Runtime-Indirektion, Admin-Editierbarkeit sind alle in Position. Die nächsten zwei Seiten behandeln den Rest der Plugin-Runtime-Geschichte: Plugin-Lifecycle geht die vier Zustände (register → activate → disable → delete) auf Model-Methoden-Ebene durch, und Testing deckt das phpunit.xml-Wiring ab, das Plugins bei jedem Host-Build in der CI hält.