Yêu cầu tiên quyết
Plugin trong codebase này là một Laravel package nhỏ. Trước khi scaffold một plugin, hãy chắc chắn rằng bản AcelleMail host mà bạn đang mở rộng đã chạy được, và bạn có một PHP toolchain hoạt động trên máy local. Các lệnh CLI bên dưới giả định bạn đang ở thư mục gốc của ứng dụng (thư mục chứa file artisan).
Ứng dụng host
- AcelleMail v4.x đã cài và đang phục vụ request. Plugin loader nằm trong
App\Providers\AppServiceProvider — các bản 3.x cũ không có Plugin::autoloadWithoutDbQuery().
- Một queue worker, scheduler, hoặc một web request có thể chạm đến application root — loader chạy ở thời điểm boot, không chạy theo yêu cầu.
- Quyền ghi vào
storage/app/plugins/. Lệnh Artisan ghi scaffold ở đây, không phải trong vendor/.
Kiến thức PHP nên ôn lại
Hệ thống plugin dựa nhiều vào một vài nền tảng cốt lõi của PHP và Laravel. Nếu bất kỳ điểm nào dưới đây có vẻ đã cũ, hãy tạm dừng và lướt qua tài liệu liên quan trước khi scaffold — debug một plugin có namespace sai trong composer.json khó hơn nhiều so với làm đúng ngay từ đầu.
- PSR-4 autoloading. File
composer.json của plugin map một namespace prefix tới thư mục src/. AcelleMail đăng ký mapping đó với một Composer\Autoload\ClassLoader mới ở thời điểm boot — vì vậy khai báo namespace trong mọi file PHP phải khớp chính xác với mapping trong composer.json, kể cả về viết hoa viết thường.
- Closure và từ khoá
use. Hầu hết hook listener đều là closure. Khi closure cần một biến bên ngoài, bạn phải capture nó tường minh. Quên điều này là nguồn lỗi undefined variable phổ biến nhất trong code plugin.
register() vs boot() trên service provider. Laravel chạy register() của mọi provider trước, sau đó mới đến boot() của mọi provider. Hook đăng ký trong register() có thể chạy trước khi dependency của nó sẵn sàng; hook đăng ký trong boot() chạy quá muộn cho translation collector. Cả hai đều là footgun thật — xem Bảy lỗi ngày đầu.
- Eloquent, Blade, Routes, Facades. Plugin migration dùng
Schema builder chuẩn, plugin view là file Blade thông thường, plugin route dùng Route::group(...). Không có gì đặc biệt về plugin — file sinh ra là Laravel thuần.
Bạn không cần publish plugin lên Packagist, chạy composer install trong thư mục plugin, hay đăng ký bất cứ thứ gì trong composer.json gốc của host. Loader ở runtime xử lý mọi bước.
Quy tắc đặt tên — đọc một lần, tiết kiệm cho bạn cả tiếng đồng hồ
Mỗi plugin có identity dạng {vendor}/{name} — ví dụ Aurius, aix/sample, athena/evs. Identity này là canonical key trong bảng database plugins, thư mục storage/app/plugins/, master file storage/app/plugins/index.json, và tên các lifecycle hook (activate_plugin_{vendor}/{name}, delete_plugin_{vendor}/{name}, icon_url_{vendor}/{name}).
Validator trong App\Model\Plugin::init() áp dụng một ruleset nhỏ, bảo thủ (regex chuẩn: ^[a-z0-9]+\/[a-z0-9]+$ với min:2 max:32 mỗi vế):
- Chỉ chữ thường và chữ số. Không dấu gạch dưới, không dấu gạch ngang, không chữ hoa. Hướng dẫn cũ cho phép dấu gạch dưới đã được thay thế — nếu bạn thấy
my_plugin trong một README cũ, dạng đó không còn hợp lệ.
- Từ hai đến ba mươi hai ký tự mỗi vế.
a/sample fail (vendor quá ngắn); team/x fail (name quá ngắn).
- Đúng một dấu gạch chéo. Vendor và name. Không lồng nhau.
Quy tắc giao tập-bảo-thủ này đến từ một đợt dọn dẹp 2026-04 đã đồng bộ Plugin::init() với Plugin::getStoragePathByName(). Hai validator giờ thống nhất cùng một regex — không còn khả năng một tên scaffold sạch sẽ nhưng sau đó load thất bại.
Chọn phần vendor cẩn thận. Vendor là một phần của mọi namespace, mọi URL prefix trong routes.php của plugin, và mọi translation key mà plugin emit ra. Đổi tên về sau đồng nghĩa với search-and-replace khắp mọi file. acmecorp/loyalty rõ ràng; x/loyalty không hợp lệ (vendor quá ngắn); acmecorp/loyaltypoints thì ổn.
Lệnh scaffold
Từ thư mục gốc của ứng dụng, chạy:
php artisan plugin:init {vendor}/{name}
Cho ví dụ chi tiết, chúng ta sẽ dùng acmecorp/loyalty — phần còn lại của trang này giả định tên đó. Hãy thay bằng tên của bạn khi tự chạy lệnh.
$ php artisan plugin:init acmecorp/loyalty
Plugin acmecorp/loyalty created & loaded!
You can find its source files in the ./storage/app/plugins/acmecorp/loyalty folder
Thông báo success được in bởi App\Console\Commands\InitPlugin, vốn là một wrapper mỏng quanh method ở tầng model App\Model\Plugin::init($name). Method đó làm mọi việc mà phần còn lại của trang này mô tả — validate, copy scaffold, render Twig, đổi tên file, sau đó gọi tiếp Plugin::register($name) để insert row vào database và boot service provider.
Đến lúc prompt quay lại, plugin đã được load vào ứng dụng đang chạy dưới dạng một package inactive. Các route khai báo trong routes.php đã có thể truy cập, view có thể render, và bất kỳ hook nào service provider đăng ký đã sống. Điều duy nhất mà activation thêm vào là những gì tác giả plugin nối với event activate_plugin_{vendor}/{name} — thường là chạy migration.
Những gì được sinh ra
Lệnh Artisan ghi một bộ file khởi tạo nhỏ vào storage/app/plugins/{vendor}/{name}/, render các placeholder Twig bên trong, và đổi tên migration placeholder. Danh sách file chính xác được hard-code trong Plugin::init() — tám file được render nội dung cộng vài asset tĩnh. Không có file nào đặc biệt; chúng là Laravel thuần mà bạn có thể tự do xoá, đổi tên, hoặc mở rộng.
Cây thư mục trên đĩa sau khi lệnh kết thúc:
storage/app/plugins/acmecorp/loyalty/
├── build.sh
├── composer.json
├── icon.svg
├── routes.php
├── database/
│ └── migrations/
│ └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
├── resources/
│ ├── lang/
│ │ └── en/
│ │ └── messages.php
│ └── views/
│ └── index.blade.php
└── src/
├── Controllers/
│ └── DashboardController.php
├── Models/
│ └── Setting.php
└── ServiceProvider.php
Tám file nhìn lướt qua
| File | Dùng để làm gì |
composer.json | Contract ở runtime: name, autoload.psr-4, và extra.laravel.providers là bắt buộc. Thiếu chúng, loader không thể đăng ký namespace hay boot provider. |
src/ServiceProvider.php | Một entry point duy nhất mà Laravel nhìn thấy. Đăng ký translation trong register(), sau đó đăng ký route, view, lifecycle hook, và icon URL trong boot(). |
src/Controllers/DashboardController.php | Một mẫu vứt đi. Trả về view index.blade.php đi kèm. Cứ thay tuỳ thích. |
src/Models/Setting.php | Một Eloquent model gắn với migration đầu tiên của plugin. Tên bảng được namespace hoá thành {vendor}_{name}_settings để plugin không thể đụng độ trên cùng một DB. |
routes.php | Được load từ service provider. Khai báo cả route phục vụ icon (dùng bởi trang admin Plugins) và một route dashboard mẫu plugins/{vendor}/{name}. |
resources/views/index.blade.php | View Hello World được render bởi DashboardController. Thay bằng UI thật của bạn. |
resources/lang/en/messages.php | File translation master. Language::dump() copy nó vào storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ ở runtime — chính các file đã dump là thứ ứng dụng thực sự đọc. |
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.php | Migration đầu tiên. Chỉ chạy khi plugin được kích hoạt, rollback khi plugin bị xoá. Tên file là cái duy nhất mà placeholder không được render bởi chính Twig — Plugin::init() đổi tên nó qua một bước str_replace riêng. |
Một plugin production thực sự sẽ vượt xa surface tối thiểu này. Tham chiếu chính tắc trong codebase là storage/app/plugins/Aurius/ — tám Eloquent model, mười bốn migration, mười tám locale, hơn sáu mươi view, một admin sidebar group, một chatbox UI bubble, và các queue-bound job riêng. Bộ khung Hello World có chủ ý giữ ở mức tối thiểu để bạn có thể thay từng mảnh một mà không cần học mọi subsystem cùng lúc. Controller phụ đặt dưới src/Controllers/, model phụ dưới src/Models/, service phụ dưới src/Services/, migration thêm dưới database/migrations/.
Plugin::register() đã làm gì sau hậu trường
Dòng output ghi created & loaded, và điều đó chính xác. Giữa lúc copy file và lúc in thông báo success, Plugin::init() gọi Plugin::register($name), vốn thực hiện năm bước riêng biệt:
- Đọc
composer.json của plugin. Trường name phải khớp chính xác với thư mục (acmecorp/loyalty) — không khớp sẽ throw exception composer name in composer.json is expected to be ….
- Tạo hoặc cập nhật row trong bảng database
plugins. title, description, và version được lấy từ metadata composer. Status được đặt là inactive.
- Ghi master file.
storage/app/plugins/index.json là registry ở thời điểm boot — AppServiceProvider::boot() đọc file này để quyết định plugin nào cần autoload, ở mỗi request, mà không cần chạm vào database. Hành động activate và disable về sau cũng mutate cùng file đó.
- Load service provider ngay tức thì.
boot() của plugin chạy trong process hiện tại, nên mọi route / view / hook mà nó đăng ký đã sống trước request tiếp theo.
- Materialise các file translation.
Language::dump() đọc mọi entry của hook add_translation_file, copy master file vào storage/app/data/plugins/..., rồi kết thúc bằng cách chạy vendor:publish --tag=plugin --force để mọi asset đi kèm đáp xuống public/plugins/....
Mental model đáng nhớ: "đã cài" đồng nghĩa với "đã load". Activation chỉ là một bước lật status cộng với những gì tác giả plugin nối vào event activation. Không có bước riêng đăng ký các route mà activation kích hoạt — route đã được đăng ký ngay khi plugin:init chạy xong.
Plugin inactive vẫn được load. Implementation hiện tại của Plugin::autoloadWithoutDbQuery() load mọi plugin được liệt kê trong index.json, bất kể status. Nếu một tính năng thật sự phải biến mất khi admin disable plugin, tác giả phải gác nó tường minh — một route middleware kiểm tra Plugin::getByName($name)->isActive() rồi abort 404 là pattern thông dụng. Plugin admin-console của chính core platform là ví dụ chính tắc.
Kích hoạt plugin
Với plugin đã scaffold và inactive, bước tiếp theo là đánh dấu nó active để listener của activate_plugin_{vendor}/{name} chạy migration. Có hai đường:
Từ admin UI
Đăng nhập với quyền admin, mở /rui/admin/plugins, tìm mục Loyalty, và click Activate. Trang render icon được phục vụ bởi routes.php của bạn (placeholder đi kèm một icon.svg ở thư mục gốc plugin — thay bằng icon của bạn để brand cho mục đó).
Bằng code (cho testing hoặc seeding)
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated
Cả hai đường đều bắn Hook::fire('activate_plugin_acmecorp/loyalty'). Service provider của bộ khung đã đăng ký một listener Hook::on(...) cho event đó trong boot() — listener gọi Artisan::call('migrate', ['--path' => ..., '--force' => true]), sinh ra bảng acmecorp_loyalty_settings.
Truy cập /plugins/acmecorp/loyalty trên trình duyệt và trang Hello World đi kèm sẽ render. Block @{{ trans('loyalty::messages.intro') }} lấy nội dung từ file translation đã dump dưới storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php.
Những chỉnh sửa đầu tiên
Bộ khung có chủ ý giữ ở mức tối thiểu để bạn có thể thay từng mảnh một mà không phải học mọi subsystem cùng lúc. Thứ tự hợp lý:
- Cập nhật
composer.json. Đặt title, description, version thật. Trang admin Plugins render các trường này.
- Thêm migration thật. Bỏ một file mới dưới
database/migrations/ với timestamp lớn hơn file hiện có. Nó sẽ chạy ở lần activate tiếp theo (hoặc sau một chu kỳ deactivate-rồi-reactivate).
- Thêm model thật. Bộ khung đi kèm
Setting như một placeholder. Thêm model riêng dưới src/Models/; namespace nó là {Vendor_class}\{Name_class}\Models\YourModel. Tên class được tự suy ra từ vendor/name chữ thường — acmecorp thành Acmecorp và loyalty thành Loyalty.
- Thay
DashboardController. Thêm các controller mà tính năng của bạn thực sự cần. Giữ chúng mỏng — đẩy business logic vào các class src/Services/.
- Thay các view. File
index.blade.php đi kèm dùng Bootstrap 5 từ CDN. Phần lớn tác giả plugin gỡ bỏ phần đó và extend layout của host application.
- Thêm hook trong
ServiceProvider::boot(). Xem đào sâu Hook system để hiểu bốn pattern. Bộ khung đã minh hoạ EVENT (Hook::on) và BEHAVIOR (Hook::set) — REGISTRY và FILTER là hai cái tiếp theo cần học.
Bảy lỗi ngày đầu và cách sửa
Gần như mọi báo cáo từ tác giả plugin mới rơi vào một trong bảy nhóm này. Mỗi nhóm bắt nguồn từ code đang chạy trong App\Model\Plugin hoặc App\Providers\AppServiceProvider, nên triệu chứng có thể đoán trước.
1. Tên vi phạm validator
plugin:init throw với thông báo Plugin name must be in the "author/name" format hoặc Author name "..." is invalid. Only lowercase letters and digits are allowed. Nguyên nhân: regex ^[a-z0-9]+\/[a-z0-9]+$ với min:2 max:32 mỗi vế từ chối dấu gạch dưới, gạch ngang, chữ hoa, hoặc vế ngắn hơn hai ký tự.
Sửa: chỉ dùng chữ thường và chữ số — ví dụ acmecorp/loyalty, không phải acme_corp/loyalty-points.
2. Tên trong composer.json không khớp với thư mục
Sau khi scaffold, Plugin::register() validate rằng name trong composer.json đã render khớp với thư mục dưới storage/app/plugins/. Sửa JSON sang vendor hoặc name khác mà không đổi tên thư mục sẽ throw Plugin name in composer.json is expected to be '{folder}', found '{json}'.
Sửa: đổi tên thư mục và JSON đồng bộ, hoặc chạy lại plugin:init với tên mới.
3. autoload.psr-4 thiếu hoặc sai dạng
loadPluginByName() throw Cannot boot plugin '{name}'. No 'autoload' found in composer.json (hoặc biến thể 'autoload.psr4') khi block autoload bị xoá hoặc sai chính tả. Runtime cần map đó để đăng ký namespace; thiếu nó thì không gì trong src/ instantiate được.
Sửa: giữ nguyên entry autoload.psr-4 đã scaffold. Namespace prefix mà nó khai báo (Acmecorp\Loyalty\\) phải khớp với khai báo namespace ở đầu mọi file PHP trong src/.
4. Khai báo namespace không khớp composer.json
Autoloader của PHP giải Acmecorp\Loyalty\Controllers\DashboardController ra src/Controllers/DashboardController.php bằng cách bóc prefix Acmecorp\Loyalty\\ khai báo trong composer.json. Nếu file khai báo namespace AcmeCorp\Loyalty\Controllers (chữ C hoa trong AcmeCorp), autoloader không tìm ra. Triệu chứng: Class "Acmecorp\Loyalty\Controllers\DashboardController" not found ngay ở request đầu tiên.
Sửa: khai báo namespace trong mọi file PHP dưới src/ phải dùng đúng kiểu viết hoa suy ra từ vendor/name chữ thường. Với acmecorp/loyalty là Acmecorp\Loyalty. Plugin::makeClassNameFromString() chỉ áp ucfirst — không có casing thông minh.
5. Hook translation đăng ký trong boot() thay vì register()
AppServiceProvider::boot() gọi Hook::collect('add_translation_file') trong chính phase boot của mình. Đến lúc boot() của plugin chạy, vòng lặp đó đã xong — thêm entry translation ở đó đồng nghĩa với việc nó không bao giờ được nhặt, và trans('loyalty::messages.intro') trả về đúng literal key.
Sửa: đăng ký translation trong register(), đúng như bộ khung đã làm. Các lifecycle hook cho activate_plugin_* và delete_plugin_* vẫn thuộc về boot().
6. Gọi $this->loadTranslationsFrom(...) trong boot()
Một bản năng phổ biến là gọi loadTranslationsFrom() của Laravel trực tiếp bên cạnh hook. Vì boot() của plugin chạy sau AppServiceProvider::boot, lệnh gọi thứ hai ghi đè lên namespace hint vốn đang trỏ tới các file runtime đã dump (storage/app/data/plugins/...) và trỏ ngược lại master file (storage/app/plugins/.../resources/lang/...). Triệu chứng nhìn thấy: 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 trở thành file zombie.
Sửa: chỉ dùng hook add_translation_file. Đừng đồng thời gọi thêm loadTranslationsFrom().
7. Hook đăng ký trong register() mà phụ thuộc vào plugin khác hoặc kernel
register() chạy trước khi register() của các provider khác hoàn tất và rất lâu trước bất kỳ boot() nào. Code cần database, service của plugin khác, hoặc bất kỳ singleton nào được nối trong register() của provider khác có thể fail với Class not found hoặc Target class does not exist. Hook duy nhất thuộc về register() là add_translation_file (vì nó phải chạy trước vòng lặp collect của AppServiceProvider::boot).
Sửa: đặt mọi hook khác trong boot(). Nếu thật sự cần chạy sớm, hãy gác nó sau app()->runningInConsole() hoặc isInitiated().
Checklist từng bước
Chuỗi đầy đủ để ship một plugin chạy được, từ đầu đến cuối:
php artisan plugin:init {vendor}/{name} — scaffold.
- Sửa
composer.json — đặt title, description, version thật.
- Viết các migration dưới
database/migrations/.
- Thêm model dưới
src/Models/.
- Thêm controller dưới
src/Controllers/.
- Thêm view dưới
resources/views/.
- Khai báo route trong
routes.php.
- Nối mọi thứ trong
ServiceProvider::boot() — view, route, hook, asset publish.
- Đăng nhập admin → Plugins → Activate. Migration chạy tự động.
Khi có gì đó hỏng, hai entry point để debug bao phủ gần như mọi trường hợp. storage/logs/laravel.log bắt mọi exception ném ra trong lúc boot, kể cả những exception phát sinh trong loadPluginByName() khi đang đăng ký autoload. Trường error trên mỗi row của storage/app/plugins/index.json hiển thị boot failure gần nhất cho plugin đó và là thứ trang admin Plugins dùng để hiện badge lỗi đỏ — xoá file bằng cách reactivate plugin (hoặc xoá rồi cài lại) sẽ reset trạng thái lỗi.
Đi tiếp ở đâu
Bạn đã có bộ khung, lifecycle, và bảy lỗi gác cửa cho phần lớn việc debug ngày đầu. Hai trang tiếp theo cho bạn mental model mà phần còn lại của tài liệu giả định:
- Plugin architecture — luồng load tại thời điểm boot, vì sao plugin inactive vẫn được autoload, cơ chế master-file, và khác biệt giữa
register() và boot() ở mức runtime.
- Hook system — bốn pattern (REGISTRY, EVENT, BEHAVIOR, FILTER), khi nào dùng cái nào, và semantics khi xung đột khiến BEHAVIOR throw lúc va chạm thay vì âm thầm ghi đè.
Khi đã sẵn sàng ship một feature plugin thật, các ví dụ chi tiết là Sending driver (Postal MTA đầu đến cuối) và Payment gateway (Paddle như một gateway theo vùng). Cho công việc UI, UI injection bao quát các hook layout/sidebar/page-slot cho phép plugin mount một chatbox bubble hoặc một settings panel mà không phải fork bất kỳ Blade nào.