Bốn pattern. Một file. Toàn bộ bề mặt mở rộng.

Mọi cách mà plugin tham gia vào core đều đi qua App\Library\HookManager. Class này khoảng 160 dòng, không có dependency, và expose chính xác bốn cặp register/execute: add/collect (REGISTRY), on/fire (EVENT), set/perform (BEHAVIOR), và modify/filter (FILTER). Trang này nói về việc mỗi pattern đảm bảo điều gì, khi nào dùng, conflict giữa hai plugin trông như thế nào, và những anti-pattern trông có vẻ đúng nhưng hỏng trong production. Mọi ví dụ đều dựa trên callsite có thật trong host application.

Bản đồ bốn pattern

Mọi hook trong codebase đều rơi vào đúng một trong bốn hình thái. Hình thái quyết định conflict semantics, cách xử lý giá trị trả về, và kiểu tương tác nào hợp lý giữa core và plugin. Chọn sai pattern sẽ hiện ra sau dưới dạng edge case lạ — biết cái nào là cái nào ngay từ đầu giúp tiết kiệm việc viết lại integration.

PatternRegisterExecuteTrả vềConflict
REGISTRYHook::add($name, $cb)Hook::collect($name)mảng các giá trị trả về của mọi callbackMerge — mọi callback đều chạy, mọi kết quả đều được giữ
EVENTHook::on($name, $cb)Hook::fire($name, [...args])không có gì — giá trị trả về bị bỏAll-fire — mọi listener đều chạy, side effect cộng dồn
BEHAVIORHook::set($name, $cb) hoặc setIfEmptyHook::perform($name, [...args])bất cứ gì callback duy nhất đã đăng ký trả vềExclusive — lần set thứ hai trên cùng tên throw ngay lập tức
FILTERHook::modify($name, $cb)Hook::filter($name, $value)giá trị, được biến đổi qua mọi callback theo thứ tự đăng kýChain — mỗi callback nhận output của callback trước

Một mental model hữu ích: REGISTRY trả lời "bạn muốn đóng góp gì"; EVENT trả lời "bạn có muốn biết không"; BEHAVIOR trả lời "tôi nên làm việc này như thế nào"; FILTER trả lời "cái này nên trở thành gì". Bốn section tiếp theo nói chi tiết từng pattern, với callsite thực tế đang chạy trong core.

REGISTRY — add() + collect()

Plugin đóng góp một hoặc nhiều item vào một danh sách được đặt tên. Host gọi collect() để lấy mảng mọi đóng góp. Mọi callback đều chạy, mọi giá trị trả về đều được giữ, theo thứ tự đăng ký.

Cơ chế

// Plugin (in ServiceProvider::boot())
Hook::add('register_sending_server_driver', fn() => [
    'type'   => 'postal',
    'driver' => '\\AcmeCorp\\Postal\\Driver',
    'name'   => 'Postal MTA',
]);

// Core (app/Model/SendingServer.php:191)
foreach (Hook::collect('register_sending_server_driver') as $meta) {
    $drivers[$meta['type']] = $meta['driver'];
}

Hook REGISTRY thực tế trong core

Hook keyNơi host collectPlugin đóng góp gì
register_sending_server_driverapp/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107Driver class metadata — type, driver (FQCN), label
register_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:120Metadata cho select-form "Add server" — icon, name, description, create_url
register_vendor_config_keysapp/Model/SendingServer.php:206Tên field cho config-form từng driver — ['my_api_key', 'my_region']
add_translation_fileapp/Model/Language.php:532 + AppServiceProvider::boot()Descriptor translation-file — locale folder, prefix, master file
captcha_methodapp/Model/Setting.php:290Metadata captcha provider — id, label, render closure
list_import_notificationsapp/Http/Controllers/SubscriberController.php:402Fragment HTML banner hiển thị phía trên dialog list-import
generate_big_notice_for_sending_serverapp/Http/Controllers/Refactor/Admin/SendingServerController.php:235Banner HTML cho trang detail của sending server
layout.head.assetsresources/views/refactor/layouts/{app,admin}.blade.phpString CSS / JS inject trước @yield('head')
layout.body.before_closeSame files, before </body>HTML cho floating widget — chatbox bubble, modal, sparkle popover
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpSection sidebar admin do plugin đóng góp

Khi nào dùng REGISTRY

  • Nhiều plugin có thể đóng góp (sending driver, captcha method, sidebar item).
  • Host muốn mọi đóng góp, không chỉ cái cuối cùng.
  • Các đóng góp compose mà không xung đột — list item, menu entry, banner fragment, configuration metadata.

Convention đặt tên

Trong codebase, tên REGISTRY thường đọc như cụm động từ mô tả đóng góp: register_sending_server_driver, add_translation_file, captcha_method, generate_big_notice_for_sending_server. Tên có động từ số ít (register_*, add_*) gợi ý "đóng góp một item", nhưng cơ chế collect() vẫn gom mọi đóng góp — không có gì phía registration giới hạn plugin chỉ một entry.

EVENT — on() + fire()

Plugin phản ứng khi có gì đó xảy ra trong host. Giá trị trả về bị bỏ — contract một chiều: core thông báo, plugin compose side effect. Mọi listener đều chạy, theo thứ tự đăng ký.

Cơ chế

// Plugin (in ServiceProvider::boot())
Hook::on('customer_added', function ($customer) {
    LoyaltyPoints::award($customer, 100, 'welcome_bonus');
});

// Core (app/Model/Customer.php:1410)
Hook::fire('customer_added', [$customer]);

Hook EVENT thực tế core bắn ra

Tên hookNơi nó fireArgs bag
customer_addedapp/Model/Customer.php:1410 + app/Http/Controllers/UserController.php:69[$customer]
user_addedapp/Model/User.php:812[$user]
new_subscriptionapp/Library/OrderFulfillment/Handlers/SubscriptionNewHandler.php:41[$subscription]
plan_changedapp/Services/Subscription/SubscriptionManagementService.php:531[$customer, $oldPlan, $newPlan]
subscription_cancelledapp/Services/Subscription/SubscriptionManagementService.php:212[$subscription]
subscription_terminatedWire qua AppServiceProvider[$subscription]
after_verify_dkim_against_aws_sesapp/SendingServers/Drivers/Vendors/Amazon/AmazonBaseDriver.php:447[$domain, $tokens]
activate_plugin_{vendor}/{name}app/Model/Plugin.php:487(không có args)
delete_plugin_{vendor}/{name}app/Model/Plugin.php:673[$keepData] — mặc định false

Khi nào dùng EVENT

  • Plugin muốn phản ứng nhưng không cần ảnh hưởng đến outcome của core.
  • Chỉ side effect — gửi webhook, ghi log, cộng điểm loyalty, dispatch queue job.
  • Core đã commit hành động trước khi event fire; listener không thể cancel.

EVENT và flag $keepData. Event delete_plugin_* là chỗ duy nhất args bag mang một tín hiệu out-of-band. $keepData = true báo cho listener "admin muốn giữ data của plugin này, bỏ qua migrate:rollback". Skeleton listener đã tôn trọng điều đó: function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }. Plugin không sở hữu data cần preserve có thể bỏ qua arg với giá trị mặc định.

BEHAVIOR — set() + perform()

Một callable sở hữu behaviour được đặt tên. Host gọi perform() để chạy bất cứ ai đang đăng ký. set() chiếm tên độc quyền — nếu caller thứ hai cố set cùng tên, HookManager::set() throw exception ngay. Không có silent override; conflict surface lúc boot, không phải production.

Cơ chế

// Plugin overrides (in ServiceProvider::boot())
Hook::set('dispatch_list_import_job', fn($list, $file) =>
    new \\AcmeCorp\\FastImport\\FasterImportJob($list, $file));

// Core registers default + executes (app/Http/Controllers/SubscriberController.php:433)
Hook::setIfEmpty('dispatch_list_import_job', function ($list, $filepath) use ($request) {
    return new ImportJob($list, $filepath, $request);
});
$currentJob = Hook::perform('dispatch_list_import_job', [$list, $filepath]);
dispatch($currentJob);

Quy tắc timing setIfEmpty

Default của host đi qua setIfEmpty(), không phải set() — sự khác biệt này là cố ý. setIfEmpty chỉ đăng ký callback nếu chưa ai claim tên đó; nếu plugin đã gọi set trong boot(), default của host bị bỏ im lặng.

Điều đó có nghĩa setIfEmpty phải được đặt ngay trước perform(), trong controller hoặc model, không phải trong service provider. Lý do: khi request handler của controller chạy, mọi ServiceProvider::boot() của plugin đã xong — bất cứ override nào plugin đăng ký qua set đã có hiệu lực. Đặt default trong register() hoặc boot() có nguy cơ chạy trước set() của plugin và khóa nó ra.

Hook BEHAVIOR thực tế trong core

Tên hookNơi host đăng ký default + performCái gì bị override
dispatch_list_import_jobapp/Http/Controllers/SubscriberController.php:433-438Job class được queue khi admin import CSV — plugin có thể swap sang variant nhanh hơn, phân tán, hoặc có instrumentation
icon_url_{vendor}/{name}app/Model/Plugin.php:632-637 (per-plugin)Image URL render cho plugin entry trên trang admin Plugins — mặc định /images/plugin.svg; plugin gọi Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) trong boot()

Khi nào dùng BEHAVIOR

  • Đúng một đoạn logic nên chạy.
  • Có default hợp lý, nhưng plugin nên có thể swap hoàn toàn.
  • Hai plugin claim cùng behaviour là bug, không phải feature — bạn muốn nó fail loudly.

BEHAVIOR exclusivity là feature, không phải vấn đề. Nếu hai plugin chính đáng cần ảnh hưởng cùng một điểm, hình thái đúng là REGISTRY (mỗi cái contribute một candidate, host chọn một) hoặc FILTER (mỗi plugin biến đổi giá trị qua một chain). Chọn BEHAVIOR cho "shared logic" ép một cuộc đua bất khả thắng giữa hai plugin author. HookManager::set() throw với Behavior "{name}" has already been registered ngay khi plugin thứ hai boot — nên conflict không thể ship vào production.

FILTER — modify() + filter()

Một giá trị đi qua một chain callback. Mỗi callback nhận giá trị hiện tại (cộng các positional args thêm) và trả về giá trị truyền cho cái tiếp theo. Host gọi <code>filter()</code> với giá trị khởi tạo; return là giá trị cuối sau khi mọi plugin đã có lượt.

Cơ chế

// Plugin (in ServiceProvider::boot())
Hook::modify('sidebar-menu-items', function (array $items) {
    array_splice($items, 1, 0, [
        ['label' => 'Loyalty', 'url' => route('loyalty_points.dashboard'), 'icon' => 'star'],
    ]);
    return $items;
});

// Core
$items = Hook::filter('sidebar-menu-items', $defaultItems);

Positional args tùy chọn

Hook::filter($name, $value, $extraParams) nhận mảng thứ ba các positional argument được truyền cho mọi callback cùng với giá trị hiện tại. Ví dụ contract từ plugin guide là maillist redirect:

// Plugin
Hook::modify('page.maillist.show.redirect', function ($redirect, $list, $request) {
    if (MyPlugin::shouldRedirect($list, $request->user())) {
        return redirect()->route('my_plugin.custom_page', $list->uid);
    }
    return $redirect; // null means "do not redirect"
});

// Core
$redirect = Hook::filter('page.maillist.show.redirect', null, [$list, $request]);
if ($redirect) {
    return $redirect;
}
// continue rendering

Khi nào dùng FILTER

  • Một giá trị đi qua nhiều plugin trước khi host dùng.
  • Mỗi plugin có thể compose với plugin trước — thêm vào menu, sửa nội dung email, điều kiện một redirect, biến đổi payload.
  • Trả về input không đổi là cách opt-out theo convention — không exception, không tín hiệu đặc biệt.

FILTER là pattern ít được dùng nhất trong code base core hiện tại. Phần triển khai HookManager ổn định (dòng 143-159 của file), và contract đã được tài liệu cho plugin author, nhưng core chưa gọi Hook::filter trong các hot path production ngoài ví dụ page.maillist.show.redirect đã tài liệu. Các hot path host-side mới muốn có plugin-composable transform nên chọn FILTER hơn BEHAVIOR — chain semantics chính xác là điều hầu hết các tình huống "tôi muốn plugin có thể thêm vào cái này" cần.

Conflict semantics across the four patterns

Khi hai plugin nhắm cùng tên hook, bốn pattern phản ứng khác nhau. Biết bạn có loại pattern nào nói cho bạn chính xác conflict trông thế nào, và liệu nó sẽ surface lúc boot hay rất lâu sau.

PatternHai plugin nhắm cùng tên làm gìSurface như thế nào
REGISTRYCả hai đóng góp đều được giữ; collect trả cả haiKhông conflict — theo thiết kế. Thứ tự là thứ tự đăng ký.
EVENTCả hai listener chạy; side effect cộng dồnKhông conflict — theo thiết kế. Thứ tự là thứ tự đăng ký.
BEHAVIORLần gọi set thứ hai throw với Behavior "{name}" has already been registeredException lúc boot — ServiceProvider::boot() của plugin fail, master file ghi error, trang admin Plugins surface red pill. Production không bao giờ thấy silent override.
FILTERGiá trị đi qua cả hai callback theo thứ tự đăng kýKhông conflict — theo thiết kế. Mỗi callback có thể opt out bằng cách trả input không đổi.

Ba trong bốn pattern không có conflict chúng aggregate. BEHAVIOR là cái duy nhất có ownership exclusive, và failure mode là exception cứng lúc boot — một lựa chọn thiết kế có chủ ý để misuse không thể tồn tại cùng một install đang chạy.

Convention args-bag

Mọi fire / collect / perform / filter đều có cùng hình thái: $name, $value (hoặc default), $params. Mảng $params được unpack theo vị trí vào callback đã đăng ký. Mọi callback trong chain nhận cùng args.

  • Giữ args bag nhỏ và ổn định. Một khi hook đã ship, args trở thành contract — thêm một positional arg break mọi plugin đã bind closure với signature cố định.
  • Truyền model, không phải field bag. fire('customer_added', [$customer]) để listener tự quyết đọc field nào; fire('customer_added', [$customer->email, $customer->uid, ...]) sẽ khóa args vào những field tồn tại lúc hook ship.
  • Dùng hook mới thay vì overload args. Nếu một slot cần context phong phú hơn, đăng ký một hook key riêng thay vì thêm positional arg thứ năm tùy chọn.

Quirk collect by-reference

Một phần nhỏ của core dùng collect() với argument by-reference như một mutation hook — nằm ngoài mô hình canonical bốn pattern. Callsite filter_aws_ses_dns_records là ví dụ tiêu biểu:

// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);

Host fire hook kỳ vọng plugin mutate $dkims$spf tại chỗ. Nói thẳng ra đây là misuse REGISTRY — một FILTER chain thật sự sẽ là hình thái đúng. Behaviour hoạt động vì closure PHP tôn trọng arg by-reference, nhưng plugin author không nên viết callsite mới theo style này. Hãy chọn FILTER (giá trị đơn được transform) hoặc REGISTRY (nhiều đóng góp immutable) thay vì.

Sáu anti-pattern cần tránh

Các pattern dưới đây đều trông đúng thoạt nhìn và hỏng theo cách tinh vi. Mỗi cái dựa trên một loại bug đã từng thấy trong plugin production.

1. Đăng ký hook trong register() khi chúng cần boot()

Hàm register() của host chạy trước boot() của mọi service provider. Hook đăng ký ở đó fire trước khi dependency được wire bởi register() của provider khác. Triệu chứng: Class not found ngay request đầu tiên, trước khi bất kỳ code plugin nào chạy main path. Fix: chỉ hook add_translation_file thuộc về register(); mọi thứ khác vào boot().

2. Chọn BEHAVIOR khi mục tiêu là "shared"

BEHAVIOR throw ở lần set thứ hai. Nếu hai plugin chính đáng cần ảnh hưởng cùng một điểm, FILTER (compose) hoặc REGISTRY (collect) là hình thái đúng. Fix: viết lại hook contract — emit một chain hoặc một list, không phải một callable exclusive.

3. Đặt setIfEmpty trong service provider

setIfEmpty chỉ có hiệu lực nếu chưa ai đăng ký. Đặt nó trong register() hoặc boot() có nghĩa là nó có thể chạy trước set() của plugin, khóa plugin ra. Fix: đặt setIfEmpty ngay phía trên lệnh perform tương ứng, trong controller hoặc model, để boot() của mọi plugin đã xong.

4. Mutate shared state bên trong callback REGISTRY

collect gọi mọi callback theo thứ tự đăng ký. Một callback ghi vào shared cache hoặc session như side effect làm hook non-deterministic — chạy nó hai lần với thứ tự plugin khác nhau cho ra state cache khác. Fix: giữ callback add thuần. Nếu đóng góp phụ thuộc vào side effect, đăng ký một EVENT listener riêng.

5. Thêm positional args vào hook đã có

Một khi hook đã trong production, plugin đã bind closure với arity gốc. Thêm positional arg thứ năm break mọi binding bỏ qua nó. Fix: đăng ký tên hook mới (customer_added_v2) và chấp nhận một window chuyển tiếp, hoặc truyền context mới qua model object đã có trong args bag.

6. Dùng collect() cho một câu trả lời duy nhất

REGISTRY trả về một mảng — ngay cả khi chỉ một plugin đăng ký, host vẫn nhận [$result]. Coi đó như câu trả lời ($first = Hook::collect(...)[0]) chọn im lặng plugin nào boot trước, không có conflict semantics. Fix: dùng BEHAVIOR nếu kỳ vọng đúng một câu trả lời.

Cách chọn pattern

Quyết định thường gói lại thành bốn câu hỏi. Trả lời theo thứ tự sẽ chọn được hình thái đúng:

  1. Nhiều plugin có nên compose không? Nếu có, REGISTRY (đóng góp độc lập) hoặc FILTER (transform có chain). Nếu không, BEHAVIOR (override exclusive).
  2. Host có cần giá trị trả về không? Nếu không, EVENT (chỉ side effect). Nếu có, REGISTRY / BEHAVIOR / FILTER.
  3. Giá trị có đi qua nhiều bàn tay không? Nếu có, FILTER (chain). Nếu mỗi bàn tay đóng góp độc lập, REGISTRY (collect).
  4. Conflict giữa hai plugin có phải là bug không? Nếu có, BEHAVIOR (fail to lúc boot). Nếu không, các pattern không-conflict.

Khi phân vân, chọn pattern lỏng hơn. REGISTRY cộng với một EVENT "out of band" cho cleanup hầu như luôn linh hoạt hơn BEHAVIOR — conflict semantics chính là thứ giết BEHAVIOR trong thực tế, và các pattern lỏng cho phép plugin compose không cần phối hợp.

Đi tiếp đến đâu

Bạn giờ có bốn pattern ở độ sâu và conflict semantics khiến mỗi hình thái có thể đoán định. Ba trang biến kiến thức này thành các bề mặt dùng hằng ngày mà một plugin thực sự sẽ chạm đến: