Plugin ở đây là gì
Plugin là một Laravel package self-contained nằm tại storage/app/plugins/{vendor}/{name}/ bên trong cài đặt host AcelleMail. Nó mang theo composer.json riêng, PSR-4 namespace riêng, service provider riêng, routes, views, migrations, và translations riêng. Cấu trúc giống hệt một tiny Laravel application — trừ một điểm phân biệt mang tính quyết định.
Host application không cài plugin qua root Composer autoloader. Không có bước composer require, không có thư mục vendor/{vendor}/{name}/, không có entry trong composer.lock. Thay vào đó, mỗi lần application boot, nó tự làm những việc sau:
- Đọc
composer.json của từng plugin.
- Register PSR-4 namespace khai báo ở đó với một instance
Composer\Autoload\ClassLoader mới.
- Gọi
App::register(...) trên các service provider liệt kê dưới extra.laravel.providers.
Quyết định này là cố ý. Coi plugin như Composer-installed package sẽ biến composer.json của host application thành một moving target — mỗi lần install, deactivate, hay upgrade đều mutate lockfile. Runtime loader giữ dependency graph của host ổn định: plugin ship cùng metadata của riêng nó, và host có thể scan, ignore, hay reorder chúng mà không đụng tới vendor/.
Năm file điều khiển toàn bộ hệ thống
Hầu như mọi behaviour trong plugin lifecycle đều implement trong năm file ở host application. Đọc source của những file này là cách nhanh nhất để xác nhận bất kỳ điều gì trong tài liệu này:
| File | Trách nhiệm |
app/Console/Commands/InitPlugin.php | Entry point CLI cho php artisan plugin:init. Wrapper mỏng quanh Plugin::init($name). |
app/Model/Plugin.php | Toàn bộ lifecycle: scaffold, register, load, activate, disable, delete, cộng máy móc cho master file. |
app/Library/HookManager.php | Các injection primitive mà plugin dùng để extend behaviour của core — REGISTRY, EVENT, BEHAVIOR, FILTER. Khoảng 160 dòng, không dependency. |
app/Providers/AppServiceProvider.php | Autoload plugin lúc boot + register translation. Call site duy nhất nối plugin vào application đang chạy. |
app/Model/Language.php | Materialise file translation của plugin vào storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. Lớp gián tiếp này cho phép admin edit translation qua Languages UI mà không đụng vào source của plugin. |
Cộng lại, năm file này chưa tới ba ngàn dòng code phía host. Plugin system nhỏ là cố ý — mọi constraint mà plugin gặp đều xuất phát từ một trong năm file đó, và không có chỗ nào khác để tìm.
Luồng boot-and-load
Mọi request, queue worker, scheduler tick, và Artisan command đều đi qua cùng một boot sequence. Lát cắt liên quan đến plugin trông như sau:
application boots
└─ AppServiceProvider::boot()
└─ Plugin::autoloadWithoutDbQuery()
└─ reads storage/app/plugins/index.json
└─ for each entry:
└─ Plugin::loadPluginByName($name)
├─ reads plugin's composer.json
├─ registers PSR-4 prefixes via Composer\Autoload\ClassLoader
└─ App::register()
├─ ServiceProvider::register() (early — translations registered here)
└─ ServiceProvider::boot() (late — routes, hooks, views, publishes)
└─ AppServiceProvider then collects every add_translation_file hook
and calls $this->loadTranslationsFrom() once per plugin.
Hai chi tiết implementation trong sequence này có ảnh hưởng quá lớn tới plugin authors:
1. Discovery lúc boot không bao giờ query database
Danh sách plugin cần load đến từ storage/app/plugins/index.json, không phải từ bảng plugins trong database. Service provider không được phép query database an toàn — tại thời điểm AppServiceProvider::boot() chạy, connection có thể chưa tồn tại (CLI command như artisan db:create) hoặc schema có thể chưa migrate (CI test setup). Lưu boot-time registry trong JSON file né tránh toàn bộ vấn đề.
Bảng DB vẫn tồn tại. Nó lưu cùng status như JSON file, cộng metadata user-facing như title, description, và version. Trang admin Plugins đọc từ DB; boot loader đọc từ JSON. Cả hai được giữ đồng bộ bởi Plugin::register(), activate(), và disable() — mỗi thay đổi status đều ghi vào cả hai store.
2. autoloadWithoutDbQuery() hiện tại load mọi plugin trong index — kể cả những plugin inactive
Implementation hiện tại iterate mọi entry trong index.json và gọi loadPluginByName trên nó, bất kể status. Lý do là thực dụng: thậm chí plugin inactive vẫn cần routes được register (để trang admin tiếp tục hoạt động khi admin click "deactivate" mà không reload ngay), và nó cần translation sẵn sàng (để bản dump-clone không bị stale).
Hệ quả là "inactive" trong plugin system AcelleMail không cùng nghĩa với "unloaded". Section tiếp theo làm rõ chính xác sự khác biệt này.
Contract của composer.json
composer.json của một plugin không chỉ là metadata — nó là runtime contract mà loader phụ thuộc vào. Các key quan trọng là:
| Key | Mục đích |
name | Canonical plugin ID. Phải khớp chính xác với tên thư mục dưới storage/app/plugins/. Plugin::register() throw nếu hai cái lệch nhau. |
autoload.psr-4 | Map namespace prefix của plugin tới src/. Bắt buộc — không có nó, loadPluginByName() throw và plugin không thể boot. |
extra.laravel.providers | Mảng các fully-qualified class name. Loader gọi App::register() trên từng cái. Bắt buộc nếu plugin muốn register routes, views, hooks, hay bất kỳ thứ gì khác. |
extra.setting-route | Cặp controller@method mà trang admin Plugins link tới như nút "Settings" của plugin. Optional — plugin không có configuration có thể bỏ qua. |
title, description, version | Hiển thị trong listing trang admin Plugins. title bắt buộc; các cái khác fall back về default. |
Autoload mapping được register tại runtime, không phải tại install. Bạn không cần chạy composer dump-autoload sau khi edit PSR-4 map của plugin — host khởi tạo một ClassLoader mới trên mỗi request và đọc lại file. Đây cũng là lý do tại sao đổi namespace của plugin chỉ cần search-and-replace cộng một request tới host.
Master file (storage/app/plugins/index.json)
Master file là một flat JSON object key bằng tên plugin. Mỗi entry tối thiểu lưu một status, cộng một error string optional khi lần boot gần nhất thất bại. Một file điển hình trông như sau:
{
"acelle/ai": { "status": "active" },
"acmecorp/loyalty": { "status": "inactive" },
"broken/sample": { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}
Ba method phía host sở hữu file này. Mọi thay đổi status đều đi qua một trong số chúng:
Plugin::updatePluginMasterFile($name, $params) — merge-write entry của một plugin. Truyền null làm argument thứ hai để xóa entry hoàn toàn (delete path).
Plugin::resetPluginMasterFile() — rebuild file từ đầu bằng cách iterate Plugin::all(). Dùng làm recovery khi JSON bị corrupt hoặc lệch đồng bộ với DB.
Plugin::getErroredPluginNames() — đọc mọi entry, trả về tên các plugin có error non-empty. Listing trang admin Plugins dùng cái này để đẩy plugin lỗi xuống cuối và surface pill error màu đỏ.
Key error được set khi autoloadWithoutDbQuery() wrap lời gọi loadPluginByName() trong try/catch và lời gọi throw. Exception message được ghi lại để admin UI có cái gì đó để show mà không cần re-run failure. Reactivate một plugin sạch sẽ xóa field này tự động.
Master file là single source of truth tại boot time. Nếu bạn cần recover từ một plugin bị stuck (admin UI down, database offline), edit trực tiếp storage/app/plugins/index.json. Request tiếp theo đọc state cập nhật và hành xử tương ứng. Row DB là long-term metadata; JSON file là runtime registry.
Timing của register() vs boot()
Laravel chạy method register() của mọi service provider trước, theo registration order, trước khi gọi bất kỳ boot() nào. Đây là Laravel cơ bản — nhưng nó có hệ quả trực tiếp trong plugin system.
Cái gì đặt vào register()
- Constants và bindings — những thứ cần tồn tại trước khi
boot() của host chạy.
- Hook
add_translation_file — và chỉ hook này. AppServiceProvider::boot() của host gọi Hook::collect('add_translation_file') trong boot phase của chính nó. Tại thời điểm boot() của plugin chạy, vòng lặp đó đã xong. Nếu plugin register translation entry trong boot(), nó không bao giờ được pick up — và trans('myname::messages.intro') trả về key literal.
Cái gì đặt vào boot()
- Routes và views —
$this->loadRoutesFrom(...), $this->loadViewsFrom(...).
- Asset publishes —
$this->publishes([...], 'plugin').
- Lifecycle event listeners —
Hook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
- Icon URL —
Hook::set('icon_url_{vendor}/{name}', ...).
- Mọi hook khác — REGISTRY
add, EVENT on, BEHAVIOR set, FILTER modify. Bất kỳ thứ gì phụ thuộc container binding, config, hay plugin khác.
Đừng gọi $this->loadTranslationsFrom(...) trong boot() của plugin. Host đã wire namespace qua hook add_translation_file, trỏ nó vào dumped runtime files dưới storage/app/data/plugins/.... Một lời gọi loadTranslationsFrom thứ hai từ boot() của plugin override hint của host và re-point namespace tới master file dưới resources/lang/.... Triệu chứng nhìn thấy được là admin edit trong Languages UI ngừng có hiệu lực tại runtime — bản dumped clone trở thành zombie file. Chỉ dùng hook thôi.
Vì sao plugin inactive vẫn ảnh hưởng app
Lời gọi autoloadWithoutDbQuery() tại boot load mọi plugin trong index.json bất kể status. Vì vậy một plugin "inactive" vẫn có mọi thứ sau được register với host:
- Routes của nó — khai báo qua
$this->loadRoutesFrom(...) trong boot().
- Views của nó — khai báo qua
$this->loadViewsFrom(...).
- Middleware aliases của nó — register qua API Laravel chuẩn.
- Hook listeners của nó — mọi
Hook::add, Hook::on, Hook::modify, Hook::set vẫn fire.
- UI fragments của nó — bất kỳ thứ gì đóng góp qua
layout.head.assets, layout.body.before_close, admin.sidebar.groups, hay page-slot REGISTRY hooks vẫn xuất hiện.
Cái mà activation thực sự thêm vào là bất kỳ thứ gì plugin author wire vào activate_plugin_{vendor}/{name}. Listener của skeleton chạy migration. Không có bước implicit "register routes khi active" hay "remove routes khi inactive" — routes đã được register ngay khi application boot.
Nếu một feature phải thực sự biến mất khi admin disable plugin, plugin author phải guard tường minh. Pattern quy ước nằm trong storage/app/plugins/acelle/console: routes luôn load, nhưng một route middleware tên console.active abort với 404 khi Plugin::getByName('acelle/console')->isActive() trả về false. Copy pattern này khi "deactivated" cần nghĩa là "không reachable".
Tương tự cho UI hooks. Nếu một chatbox bubble inject qua layout.body.before_close cần ẩn khi plugin inactive, body closure phải check Plugin::enabled('myvendor/myplugin') trước và trả về null khi false. Host filter các giá trị return falsy ra một cách tự động trước khi render.
Lifecycle: register / activate / disable / delete
Bốn trạng thái, bốn method phía host. Mỗi cái precise về việc nó làm gì và không làm gì.
Register / install
Plugin::register($name) là entry point — nó được gọi tự động ở cuối plugin:init và trên mọi upload thành công qua admin UI. Năm bước là:
- Đọc
composer.json, copy title / description / version vào model.
- Insert hoặc update row trong
plugins với status = inactive.
- Ghi
storage/app/plugins/index.json với { "name": { "status": "inactive" } }.
- Gọi
Plugin::load($withServiceProvider = true) — register PSR-4 prefix và boot service provider ngay lập tức, để bất kỳ routes / views / hooks nào trở thành live trong process hiện tại.
- Gọi
Language::dump() để materialise các file translation, sau đó chạy vendor:publish --tag=plugin --force để copy mọi asset bundled vào public/plugins/....
Sau register, plugin đã installed và loaded. Cái duy nhất còn thiếu là bất kỳ thứ gì plugin chọn wire vào event activate — điển hình là chạy migration.
Activate
$plugin->activate() được gọi từ nút "Activate" của admin UI (và từ tests / seeders gọi model trực tiếp). Nó làm bốn việc, theo thứ tự:
- Fire
Hook::fire('activate_plugin_'.$name). Listener của skeleton chạy artisan migrate trên storage/app/plugins/{vendor}/{name}/database/migrations. Plugin khác có thể register thêm listener — REGISTRY behavior, mọi listener đều fire.
- Re-validate
composer.json của plugin với danh sách required-keys của host (name, version, app_version).
- Set
status trong DB thành active.
- Update master file:
{ "status": "active", "error": null } — xóa mọi boot error trước đó.
Disable
$plugin->disable() chỉ:
- Set
status trong DB thành inactive.
- Update master file với status mới và xóa mọi
error đã ghi.
Nó không unload routes, views, service provider, hook listeners, hay bất cứ thứ gì khác đã được register tại boot. Host không có khái niệm "unregister một service provider" — bản thân Laravel không support điều đó. Disable là một status flip, không phải unload.
Delete
$plugin->deleteAndCleanup($keepData = false) đi qua toàn bộ teardown:
- Fire
Hook::fire('delete_plugin_'.$name, [$keepData]). Listener của skeleton chạy migrate:rollback; $keepData = true có thể skip bước đó cho các plugin sở hữu data mà admin muốn giữ lại.
- Recursively delete thư mục plugin dưới
storage/app/plugins/....
- Delete row khỏi bảng DB
plugins.
- Remove entry khỏi master file.
Cho đến khi request tiếp theo boot một process mới, service provider của plugin vẫn còn loaded trong memory. Request tiếp theo đọc master file (đã shrunk), không load plugin, và state trong process bị bỏ đi cùng với request lifecycle.
Hai layer injection
Một plugin ảnh hưởng tới host application qua hai layer song song. Phân biệt giữa chúng là cái làm cho phần còn lại của tài liệu map gọn gàng vào code.
Layer 1 — Đăng ký Laravel
Qua service provider, plugin dùng các API container Laravel chuẩn để extend application:
$this->loadRoutesFrom(__DIR__ . '/../routes.php') — thêm HTTP surface của plugin.
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — expose các Blade view dưới namespace myname::view.
$this->publishes([...], 'plugin') — copy các asset bundled vào public/plugins/{vendor}/{name}/ của host khi install.
- Middleware aliases, container bindings, console commands, scheduled tasks, queue listeners — mọi thứ Laravel bản thân nó support.
Layer 2 — Injection qua Hook
Host gọi vào các primitive trong App\Library\HookManager tại các extension point được chọn kỹ. Plugin register listener trên các point đó để tham gia. Có chính xác bốn pattern: REGISTRY, EVENT, BEHAVIOR, FILTER. Deep-dive tiếp theo — The Hook system — cover từng cái đầy đủ.
Hai điều cần biết ngay: (1) mọi hook host fire là một contract ổn định — một khi đã publish, tên và signature không thay đổi giữa các release. (2) BEHAVIOR là exclusive — nếu hai plugin cùng cố Hook::set cùng tên, lời gọi thứ hai throw ngay. Không có override im lặng; conflict surface tại boot, không phải trong production.
Codebase ship ba layout-level REGISTRY hook mà gần như mọi plugin extend UI đều dùng:
| Hook key | Fire ở đâu | Dùng cho |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php, trước @yield('head') | CSS / JS phải load trước page content (chatbox styles, sparkle popover scripts) |
layout.body.before_close | Cùng layouts, ngay trước </body> | Floating widgets — chatbox bubble, modals, sparkle popover |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Các section admin sidebar do plugin đóng góp |
Cả ba theo cùng một idiom: mỗi callback trả về rendered HTML hoặc null; host iterate với array_filter và emit từng fragment với {!! !!}. Return null là cách quy ước để gate đóng góp theo feature flag hay status của plugin mà không cần throw.
Luồng translation tại runtime
Translation của plugin không được serve trực tiếp từ thư mục source resources/lang/ của plugin. Luồng là gián tiếp, và gián tiếp đó là cái cho phép admin edit translation qua Languages UI của host mà không commit vào source file của plugin. Trình tự đã verify:
register() của plugin đóng góp một entry Hook::add('add_translation_file', ...) trỏ vào storage/app/data/plugins/{vendor}/{name}/lang/.
AppServiceProvider::boot() của host collect mọi entry như vậy và gọi $this->loadTranslationsFrom() trên từng cái.
- Trên mỗi
Plugin::register(), host gọi Language::dump().
Language::dump() đọc master file của plugin tại resources/lang/en/messages.php và copy nó vào storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php cho mọi locale được support.
- Languages admin UI edit các file runtime đã dump. Master file source của plugin nguyên vẹn.
Hai path cần nhớ:
- Master file (bạn edit trong source):
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
- Runtime files (auto-generated, cái app thực sự đọc):
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php
Khi bạn edit master file, chạy php artisan translation:upgrade để re-sync master vào mọi file runtime locale (giữ lại mọi translation admin đã edit qua Languages UI). Toàn bộ cơ chế — master vs runtime, semantics upgrade, fallback per-locale — có một deep-dive riêng trong Translations.
Điều này hàm ý gì cho plugin authors
Năm rule rút ra từ kiến trúc ở trên. Internalise chúng biến hầu hết complexity bề mặt trong phần còn lại của tài liệu thành một check ngược lại danh sách này.
- Coi
boot() như phase registration. Routes, views, hooks, lifecycle listeners — hầu như mọi thứ vào đây. Thứ duy nhất vào register() là hook add_translation_file (vì host collect nó trước bất kỳ boot() nào của plugin).
- Inactive không có nghĩa là unloaded. Bất cứ thứ gì bạn register tại boot đều live bất kể status
active / inactive. Nếu một feature phải thực sự biến mất khi disabled, gate nó tường minh bằng route middleware hoặc check Plugin::enabled(...) bên trong hook closure.
- Edit translation qua master file, không bao giờ qua
loadTranslationsFrom() trực tiếp. Bản dumped clone dưới storage/app/data/plugins/... là cái runtime đọc. Tự trỏ namespace tới master directory override hint của host và break Languages UI.
- Giữ
composer.json mỏng và ổn định. Runtime loader đọc nó trên mỗi request. autoload.psr-4, extra.laravel.providers, name, title là các key host thực sự dùng. Thêm key khác cũng được nhưng không có tác dụng gì.
- Bốn hook pattern là contract duy nhất. Khi bạn thấy mình muốn "import" một core class để extend nó — dừng lại. Plugin contract là một chiều: core khai báo hook, plugin react. Nếu extension point bạn cần chưa tồn tại dưới dạng hook, nước đi đúng là file một issue cho host, không phải
use Acelle\Model\Customer từ controller của plugin.
Đi tiếp tới đâu
Bạn đã có kiến trúc. Hai trang tiếp theo biến mô hình tư duy này thành các API dùng hằng ngày mà bạn sẽ với tới:
- The Hook system — bốn pattern ở độ sâu, với call-site thật grep ra từ core. Semantics conflict, khi nào dùng pattern nào, và các anti-pattern trông đúng nhưng vỡ trong production.
- UI injection — các layout-level hook ở trên, cộng contract
page.{controller}.{action}.{slot} cho phép plugin inject một card vào page có sẵn mà không cần fork một Blade nào.
Khi bạn sẵn sàng ship một feature plugin thật, các worked example là Sending drivers (Postal MTA end-to-end) và Payment gateways (Paddle như một regional gateway). Cho một bài tập reading-comprehension hoàn chỉnh, showcase Aurius đi qua plugin phức tạp điển hình: tám model, mười bốn migration, mười tám locale, và mọi hook surface dùng trong production.