Master file. Runtime dump. Admin sửa được. Không cần fork source của plugin.

Bản English của một plugin sống trong source tree của nó. Bản đã dịch sống tại storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — sinh ra lúc install, sửa được qua UI Languages của host, và không bao giờ ghi ngược lại file source của plugin. Trang này nói về toàn bộ tầng gián tiếp: hook REGISTRY add_translation_file, nơi các bản dump-clone đi tới, bẫy phá UI Languages, và convention mười tám locale từ acelle/ai.

Vì sao flow phải gián tiếp

Tác giả plugin viết English (và optionally một vài bản dịch chính) trong source tree. Admin trên production muốn sửa string trên install đang chạy của họ — fix typo, làm mềm label, dịch thêm một locale — mà không cần đụng tới source code của plugin. Cả hai audience đều cần làm việc trên cùng một bộ key, nhưng họ không thể share cùng một file: sửa source trên một production instance sẽ bị xoá sạch ở lần upgrade plugin tiếp theo, và sửa bản deploy mirror source thì file trong source control không bao giờ thấy fix đó.

Plugin system giải bài toán này bằng một runtime dump. Plugin ship một master file (một file cho mỗi logical area) dưới source resources/lang/en/; lúc install, host copy master đó vào storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ cho mọi locale mà host support. Các bản dump-clone chính là cái mà trans() đọc lúc runtime; UI Languages admin của host sửa các bản dump-clone; source plugin nằm im. Re-install plugin sẽ chạy lại dump, pick up bất kỳ key mới nào tác giả plugin đã thêm — mà không ghi đè translation locale admin đã sửa trong khi đó.

Flow năm bước

Đây là đường đi đã xác thực, từ source plugin của bạn cho tới string render trên production:

  1. Method register() của plugin gọi Hook::add('add_translation_file', ...), đóng góp một descriptor cho mỗi translation file logic (đường dẫn file, folder locale, prefix namespace).
  2. Mỗi request, AppServiceProvider::boot() của host gọi Hook::collect('add_translation_file') và iterate các contribution, gọi $this->loadTranslationsFrom() cho từng entry.
  3. Trong Plugin::register() (được gọi tự động ở cuối plugin:init và sau mỗi lần upload thành công), host gọi Language::dump().
  4. Language::dump() đọc từng descriptor đã đăng ký và copy master file vào storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ — một lần cho mỗi locale mà host support.
  5. Admin sửa các runtime file đã dump qua UI Languages admin. Các call trans() trong view Blade của plugin đọc chính các bản dump-clone đã được sửa đó, không bao giờ đọc master file source của plugin.

Đăng ký với add_translation_file

Service provider của skeleton cho thấy registration chính tắc. Mỗi entry là một contribution REGISTRY duy nhất:

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

Mỗi key trong descriptor đều có vai trò:

KeyHost làm gì với nó
idĐịnh danh ổn định cho entry — UI Languages admin nhóm các file theo id.
plugin_nameIdentity {vendor}/{name} của plugin. Cho phép UI admin link entry translation về plugin sở hữu.
file_titleLabel dạng human-readable hiển thị phía trên danh sách string có thể sửa trong UI admin.
translation_folderNơi loadTranslationsFrom() đăng ký namespace. Phải trỏ vào dumped runtime path dưới storage/app/data/plugins/..., không trỏ vào source plugin.
translation_prefixPrefix namespace mà Blade reach tới bằng trans('prefix::messages.foo'). Theo convention là segment name của plugin để giữ uniqueness.
file_nameFile nào bên trong folder locale mà entry này map tới. Plugin có nhiều surface translation đăng ký một entry cho mỗi file.
master_translation_fileĐường dẫn absolute tới master file trong source control. Language::dump() đọc từ đây; các bản dump-clone ghi vào translation_folder.

Vì sao register(), không phải boot()

AppServiceProvider::boot() của host gọi Hook::collect('add_translation_file') trong boot phase của chính nó. Laravel chạy register() của mọi service provider trước, rồi mới chạy boot() của mọi provider — nên đến lúc boot() của plugin chạy, collect loop của host đã chạy xong. Một plugin đăng ký entry add_translation_file trong boot() đóng góp sau khi host đã ngừng nhìn, và entry đó không bao giờ được pick up. Triệu chứng nhìn thấy là trans('loyalty::messages.intro') trả về chính literal key — không translation, không fallback.

Đây là hook translation-related duy nhất nằm trong register(). Các lifecycle hook (activate_plugin_*, delete_plugin_*), route, view, và mọi hook khác đều nằm trong boot().

Bẫy double-load

Bản năng thông thường là đăng ký qua hook rồi cũng gọi luôn $this->loadTranslationsFrom() chuẩn của Laravel trong boot() để chắc cú. Không — đó là một silent override.

Collect loop của host chạy trước và trỏ namespace của plugin vào dumped runtime folder dưới storage/app/data/plugins/.... boot() của plugin chạy sau host, và một call loadTranslationsFrom() nữa từ plugin trỏ namespace sang bất kỳ path nào plugin pass vào — thường là folder source resources/lang/. Last call wins, nên runtime kết cục đọc thẳng master file của source.

Triệu chứng nhìn thấy là các sửa đổi của admin trong UI Languages không còn có hiệu lực ở runtime. Các bản dump-clone biến thành file zombie: có trên đĩa, admin đã sửa, nhưng không bao giờ được đọc vì namespace hint trỏ chỗ khác. Đây là bẫy mà SOURCE_OF_TRUTH gọi tên thẳng.

Chỉ dùng hook add_translation_file. Đừng gọi thêm $this->loadTranslationsFrom() từ boot() của plugin. Ngoại lệ duy nhất là khi bạn cần một lookup path non-namespaced (plugin acelle/ai làm vậy để các legacy key trans('refactor/ai_chatbox.foo') vẫn chạy mà không cần prefix namespace) — và ngay cả khi đó, trỏ vào resources/lang/ của source plugin để fallback thôi, không trỏ vào dump path.

Master file vs runtime file — hai path cần nhớ

PathLà cái gì
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php Master file. Sống trong source tree của plugin. Bạn sửa file này khi thêm key mới hay ship bản English mới. git commit track nó. Language::dump() đọc từ đây.
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php Runtime file. Một file mỗi locale. Sinh ra bởi Language::dump() lúc install plugin và khi chạy php artisan translation:upgrade. UI Languages admin của host sửa các file này; trans() đọc từ các file này. Không commit vào source control — bản English của tác giả plugin sống trong master, các bản locale sống trên từng install riêng biệt.

Khi bạn ship một bản update plugin có thêm key mới, bạn sửa file master trong source. Khi build mới deploy lên production install, admin chạy php artisan translation:upgrade (hoặc call Plugin::register() tiếp theo tự làm điều đó) và key mới xuất hiện trong runtime file của mọi locale với giá trị English làm initial translation. Các giá trị đã dịch cho key cũ được preserve.

Tách thành nhiều translation file

Một plugin nhỏ với một logical area duy nhất (settings, dashboard) thì một master messages.php là đủ. Plugin lớn hơn được lợi khi tách — mỗi file trở thành một entry sửa được riêng trong UI Languages admin, và các translator song song có thể làm trên các file khác nhau mà không conflict. Pattern là một call Hook::add('add_translation_file', ...) cho mỗi file.

Ví dụ chính tắc là acelle/ai tại storage/app/plugins/acelle/ai/src/ServiceProvider.php:175-197. Plugin đăng ký chín translation file tách biệt, một cho mỗi 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",
        ];
    });
}

Việc tách cho phép translator support làm trên copy chatbox mà không đụng tới label audit log admin — và cho UI admin surface ra trang edit từng file gọn trong một màn hình thay vì một cuộn dài 1.000 dòng.

Convention mười tám locale

AcelleMail ship translation cho mười tám locale: English, Vietnamese, Russian, Korean, Japanese, Chinese, German, French, Spanish, Portuguese, Italian, Dutch, Polish, Swedish, Ukrainian, Turkish, Arabic, Hindi. Một check bên trong storage/app/data/plugins/acelle/ai/lang/ xác nhận pattern: mười bảy folder locale nằm cạnh source en, mỗi folder có đủ bộ dump-clone file.

Công việc của tác giả plugin là ship một master file chỉ bằng English. Language::dump() sinh ra mười bảy folder locale ngoài English bằng cách copy master English vào mỗi folder — mọi key bắt đầu bằng giá trị English, và UI Languages admin của host cung cấp workflow để dịch chúng. Không có yêu cầu phải ship sẵn locale đã dịch trong source plugin của bạn. Làm vậy là ổn khi bạn có draft machine-translation để seed UI admin, nhưng đó không phải chuẩn — đa số plugin ship English-only và để install lo dịch.

Dùng trans() trong view của plugin

Syntax Blade khớp với translation_prefix bạn đã đăng ký. Với 'translation_prefix' => 'loyalty' của skeleton:

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


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

Controller và service của chính plugin có thể dùng __() với cùng prefix namespace:

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

Khi key đã đăng ký không resolve được (typo, key thiếu, hoặc hook add_translation_file chạy trong boot() thay vì register()), Laravel trả về literal key làm string render ra. Thấy loyalty::messages.intro trên page chính là triệu chứng chính tắc "translation chưa wire up".

translation:upgrade — sync lại sau khi sửa master file

Sau khi sửa master file trong source plugin — thêm key mới, fix typo trong copy English — tác giả plugin cần các runtime file pick up thay đổi. Hai cách:

  1. Re-install plugin. Plugin::register() gọi Language::dump() là một trong năm bước. Dump preserve mọi key admin đã dịch và thêm key mới với giá trị master English làm initial translation.
  2. Chạy thẳng artisan command: php artisan translation:upgrade. Cùng tác dụng, không cần re-install plugin. Hữu ích trong development khi bạn iterate trên copy của master file.

Cả hai đường đều non-destructive — translation admin đã sửa vẫn sống. Behaviour là "merge key mới từ master vào runtime, để yên giá trị runtime đã tồn tại". Một key English mới xuất hiện trong runtime file của mọi locale với giá trị English, sẵn sàng cho admin dịch.

Năm anti-pattern

1. Đăng ký add_translation_file trong boot()

Collect loop của host đã chạy xong trước boot() của bạn. Hook fire thành công nhưng không bao giờ được pick up. Fix: chỉ có đăng ký translation-file mới vào register(); mọi thứ khác ở lại trong boot().

2. Gọi $this->loadTranslationsFrom() song song với hook

Trỏ lại namespace vào folder source của bạn, giết chết các dump-clone ở runtime. Các sửa đổi của UI Languages admin trở thành vô hình. Fix: chỉ dùng hook; nếu thực sự cần một fallback path non-namespaced (hiếm — xem case acelle/ai), trỏ nó explicit vào source plugin mà không ghi đè namespace hint.

3. Trỏ translation_folder vào source plugin

Cùng tác hại như bẫy trước, qua một đường khác. Host đăng ký namespace của bạn vào bất kỳ path nào bạn pass — pass source path thì dump-clone không bao giờ được đọc. Fix: luôn set translation_folder vào dumped runtime path dưới storage/app/data/plugins/{vendor}/{name}/lang/.

4. Sửa file dump-clone trong source repo plugin

Lỗi dễ mắc khi reach tay "để tôi dịch thử một string này thôi". Các dump-clone là install-specific — chúng sống trong storage/app/data/, vốn gitignore trên mọi install AcelleMail. Sửa chúng trong source không có tác dụng; install tiếp theo sẽ chạy lại dump() từ master source của bạn và ghi đè bất cứ gì bạn đã đặt vào path clone. Fix: ship master locale đã dịch sẵn dưới resources/lang/{locale}/ trong source nếu bạn muốn pre-translation; dump() sẽ copy từ en chỉ khi không có master locale-specific để copy.

5. trans('messages.foo') trần không có prefix namespace

Laravel resolve key không có namespace vào folder lang của host, vốn không chứa string của plugin bạn. Trả về literal key. Fix: luôn prefix với translation_prefix bạn đã đăng ký: trans('loyalty::messages.foo').

Đi tiếp đến đâu

Translations đóng vòng "chất lượng" ở phía persistence — schema isolation, runtime indirection, admin editability đã vào chỗ. Hai trang tiếp theo lo nốt phần runtime của plugin: Plugin lifecycle đi qua bốn state (register → activate → disable → delete) ở mức model-method, và Testing nói về wiring phpunit.xml giữ plugin trong CI mỗi lần host build.