Mô hình tư duy mà phần còn lại của tài liệu này giả định.

Một plugin trong codebase này là một Laravel package nhỏ — nhưng cách host application load nó không giống cách Composer load package thông thường. Không có bước cài vào vendor/, không có entry trong composer.lock, không có autoload regeneration. Mỗi plugin được register tại runtime, từ một JSON master file duy nhất, bởi một Composer\Autoload\ClassLoader mới mà host khởi tạo bên trong AppServiceProvider::boot(). Khi đã nắm được bức tranh này, mọi phần còn lại của developer docs — hooks, sending drivers, payment gateways, UI injection, lifecycle — đều ghép vào quanh nó một cách gọn gàng.

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:

  1. Đọc composer.json của từng plugin.
  2. Register PSR-4 namespace khai báo ở đó với một instance Composer\Autoload\ClassLoader mới.
  3. 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:

FileTrách nhiệm
app/Console/Commands/InitPlugin.phpEntry point CLI cho php artisan plugin:init. Wrapper mỏng quanh Plugin::init($name).
app/Model/Plugin.phpToàn bộ lifecycle: scaffold, register, load, activate, disable, delete, cộng máy móc cho master file.
app/Library/HookManager.phpCá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.phpAutoload plugin lúc boot + register translation. Call site duy nhất nối plugin vào application đang chạy.
app/Model/Language.phpMaterialise 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à:

KeyMục đích
nameCanonical 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-4Map 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.providersMả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-routeCặ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, versionHiể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 listenersHook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
  • Icon URLHook::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à:

  1. Đọc composer.json, copy title / description / version vào model.
  2. Insert hoặc update row trong plugins với status = inactive.
  3. Ghi storage/app/plugins/index.json với { "name": { "status": "inactive" } }.
  4. 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.
  5. 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ự:

  1. 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.
  2. Re-validate composer.json của plugin với danh sách required-keys của host (name, version, app_version).
  3. Set status trong DB thành active.
  4. 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:

  1. 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.
  2. Recursively delete thư mục plugin dưới storage/app/plugins/....
  3. Delete row khỏi bảng DB plugins.
  4. 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 keyFire ở đâuDùng cho
layout.head.assetsresources/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_closeCùng layouts, ngay trước </body>Floating widgets — chatbox bubble, modals, sparkle popover
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpCá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:

  1. 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/.
  2. AppServiceProvider::boot() của host collect mọi entry như vậy và gọi $this->loadTranslationsFrom() trên từng cái.
  3. Trên mỗi Plugin::register(), host gọi Language::dump().
  4. 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.
  5. 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.

  1. 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).
  2. 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.
  3. 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.
  4. 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ì.
  5. 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.