4 パターンマップ
コードベース内のすべてのフックはちょうど 4 つの形のいずれかに収まります。その形がコンフリクトセマンティクス、戻り値の扱い、コアとプラグイン間でどのような相互作用が筋が通るかを決定します。間違ったパターンに手を伸ばすと、後から奇妙なエッジケースとして現れます — どれがどれかを最初に知っておけば、統合の書き直しを節約できます。
| パターン | 登録 | 実行 | 戻り値 | コンフリクト |
| REGISTRY | Hook::add($name, $cb) | Hook::collect($name) | 各コールバックの戻り値の配列 | マージ — すべてのコールバックが実行され、すべての結果が残ります |
| EVENT | Hook::on($name, $cb) | Hook::fire($name, [...args]) | なし — 戻り値は破棄されます | 全発火 — すべてのリスナーが実行され、副作用が合成されます |
| BEHAVIOR | Hook::set($name, $cb) または setIfEmpty | Hook::perform($name, [...args]) | 登録された唯一のコールバックが返すもの | 排他的 — 同じ名前に対する 2 度目の set は即座に例外を投げます |
| FILTER | Hook::modify($name, $cb) | Hook::filter($name, $value) | 値が登録順に各コールバックを通じて変換されたもの | チェイン — 各コールバックが前のコールバックの出力を受け取ります |
有用なメンタルモデル: REGISTRY は「何を寄与したいか」に答え、EVENT は「知りたいか」に答え、BEHAVIOR は「どうやってこれをすべきか」に答え、FILTER は「これは何になるべきか」に答えます。次の 4 つのセクションでは、コアに同梱される実際の呼び出し箇所と共に、それぞれを深く解説します。
REGISTRY — add() + collect()
プラグインが名前付きリストに 1 つ以上の項目を寄与します。ホストは collect() を呼び出してすべての寄与の配列を取得します。すべてのコールバックが実行され、すべての戻り値が登録順に捕捉されます。
メカニクス
// 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'];
}
コア内の実際の REGISTRY フック
| フックキー | ホストが収集する箇所 | プラグインが寄与するもの |
register_sending_server_driver | app/Model/SendingServer.php:191 + app/Http/Controllers/Refactor/Admin/SendingServerController.php:107 | ドライバークラスのメタデータ — type、driver (FQCN)、ラベル |
register_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:120 | 「サーバー追加」select-form メタデータ — icon、name、description、create_url |
register_vendor_config_keys | app/Model/SendingServer.php:206 | ドライバーごとの設定フォームフィールド名 — ['my_api_key', 'my_region'] |
add_translation_file | app/Model/Language.php:532 + AppServiceProvider::boot() | 翻訳ファイル記述子 — ロケールフォルダ、プレフィックス、マスターファイル |
captcha_method | app/Model/Setting.php:290 | CAPTCHA プロバイダーメタデータ — id、ラベル、render クロージャ |
list_import_notifications | app/Http/Controllers/SubscriberController.php:402 | リストインポートダイアログの上に表示される HTML バナーフラグメント |
generate_big_notice_for_sending_server | app/Http/Controllers/Refactor/Admin/SendingServerController.php:235 | 送信サーバー詳細ページ用の HTML バナー |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php | @yield('head') の前に注入される CSS / JS 文字列 |
layout.body.before_close | Same files, before </body> | フローティングウィジェット HTML — チャットボックスバブル、モーダル、sparkle popover |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | プラグインが寄与する管理サイドバーセクション |
REGISTRY を使うとき
- 複数のプラグインが寄与する可能性がある (送信ドライバー、CAPTCHA 方式、サイドバー項目)。
- ホストがすべての寄与を欲する、最後のものだけではなく。
- 寄与がコンフリクトなく合成される — リスト項目、メニューエントリ、バナーフラグメント、設定メタデータ。
命名規約
コードベース内では、REGISTRY 名は寄与を説明する動詞句として読めることが多いです: register_sending_server_driver、add_translation_file、captcha_method、generate_big_notice_for_sending_server。単数動詞の名前 (register_*、add_*) は「これを 1 つ寄与する」を示唆しますが、collect() のメカニクスは依然としてすべての寄与を集めます — 登録側でプラグインを 1 エントリに制限するものはありません。
EVENT — on() + fire()
ホストで何かが起きたときにプラグインが反応します。戻り値は破棄されます — 契約は一方向です: コアが通知し、プラグインが副作用を合成します。すべてのリスナーが登録順に実行されます。
メカニクス
// 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]);
コアが発火する実際の EVENT フック
| フック名 | 発火箇所 | 引数バッグ |
customer_added | app/Model/Customer.php:1410 + app/Http/Controllers/UserController.php:69 | [$customer] |
user_added | app/Model/User.php:812 | [$user] |
new_subscription | app/Library/OrderFulfillment/Handlers/SubscriptionNewHandler.php:41 | [$subscription] |
plan_changed | app/Services/Subscription/SubscriptionManagementService.php:531 | [$customer, $oldPlan, $newPlan] |
subscription_cancelled | app/Services/Subscription/SubscriptionManagementService.php:212 | [$subscription] |
subscription_terminated | AppServiceProvider 経由で配線 | [$subscription] |
after_verify_dkim_against_aws_ses | app/SendingServers/Drivers/Vendors/Amazon/AmazonBaseDriver.php:447 | [$domain, $tokens] |
activate_plugin_{vendor}/{name} | app/Model/Plugin.php:487 | (引数なし) |
delete_plugin_{vendor}/{name} | app/Model/Plugin.php:673 | [$keepData] — デフォルト false |
EVENT を使うとき
- プラグインは反応したいが、コアの結果に影響を与える必要はない。
- 副作用のみ — Webhook 送信、ログ書き込み、ロイヤルティポイント付与、Queue ジョブのディスパッチ。
- イベント発火時点でコアはすでにアクションをコミット済み。リスナーはそれをキャンセルできない。
EVENT と $keepData フラグ。 delete_plugin_* イベントは、引数バッグが帯域外シグナルを運ぶ唯一の場所です。$keepData = true はリスナーに「管理者がこのプラグインのデータを保持したい、migrate:rollback をスキップせよ」と伝えます。スケルトンリスナーはすでにこれを尊重しています: function ($keepData = false) { if ($keepData) return; Artisan::call('migrate:rollback', [...]); }。保持すべきデータを所有しないプラグインはデフォルトのまま引数を無視できます。
BEHAVIOR — set() + perform()
1 つの callable が名前付き動作を所有します。ホストは perform() を呼んで現在登録されているものを実行します。set() は名前を排他的に主張します — 2 つ目の呼び出し元が同じ名前を set しようとすると、HookManager::set() は即座に例外を投げます。サイレントなオーバーライドはなく、コンフリクトは本番ではなくブート時に表面化します。
メカニクス
// 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);
setIfEmpty のタイミングルール
ホストのデフォルトは set() ではなく setIfEmpty() を通ります — その違いは意図的です。setIfEmpty はまだ誰もその名前を主張していない場合にのみコールバックを登録します。プラグインがすでに boot() で set を呼んでいれば、ホストのデフォルトはサイレントにスキップされます。
つまり setIfEmpty はコントローラーまたはモデル内の perform() の直前に配置する必要があり、Service Provider に置いてはいけません。理由: コントローラーのリクエストハンドラーが実行される頃には、すべてのプラグインの ServiceProvider::boot() が終了しています — つまり set 経由のプラグインオーバーライドはすでに有効です。デフォルトを register() や boot() に置くと、プラグインの set() より先に走ってロックアウトしてしまうリスクがあります。
コア内の実際の BEHAVIOR フック
| フック名 | ホストがデフォルト + perform を登録する箇所 | オーバーライドされるもの |
dispatch_list_import_job | app/Http/Controllers/SubscriberController.php:433-438 | 管理者が CSV をインポートしたときにキューされるジョブクラス — プラグインはより高速、分散、計測可能なバリアントに差し替え可能 |
icon_url_{vendor}/{name} | app/Model/Plugin.php:632-637 (per-plugin) | 管理画面の Plugins ページでプラグインエントリ用にレンダリングされる画像 URL — デフォルトは /images/plugin.svg。プラグインは boot() で Hook::set('icon_url_acme/sample', fn() => route('plugin.acme.sample.icon')) を呼びます |
BEHAVIOR を使うとき
- ちょうど 1 つのロジックだけが走るべき。
- 妥当なデフォルトは存在するが、プラグインが完全に差し替えられるべき。
- 2 つのプラグインが同じ動作を主張するのはバグであり、機能ではない — 大きな声で失敗してほしい。
BEHAVIOR の排他性は機能であり、問題ではない。 2 つのプラグインが正当に同じ動作に影響を与える必要があるなら、正しい形は REGISTRY (各々が候補を寄与し、ホストが選ぶ) か FILTER (各プラグインがチェインで値を変換する) です。「共有ロジック」のために BEHAVIOR に手を伸ばすと、2 人のプラグイン作成者の間で勝てないレースを強います。HookManager::set() は 2 つ目のプラグインがブートした瞬間に Behavior "{name}" has already been registered で例外を投げます — つまりコンフリクトを本番にデプロイすることは不可能です。
FILTER — modify() + filter()
値がコールバックのチェインを通過します。各コールバックは現在の値 (および追加の位置引数) を受け取り、次に渡す値を返します。ホストは初期値で <code>filter()</code> を呼び、すべてのプラグインが番を終えた後の最終値が戻り値です。
メカニクス
// 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);
オプションの位置引数
Hook::filter($name, $value, $extraParams) は、現在の値と共にすべてのコールバックに渡される位置引数の 3 番目の配列を受け付けます。プラグインガイドからの契約例は maillist リダイレクトです。
// 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
FILTER を使うとき
- 値がホストに使われる前に複数のプラグインを通過する。
- 各プラグインが前のものと合成できる — メニューへの追加、メールコンテンツの変更、リダイレクトの条件付け、ペイロードの変換。
- 変更されていない入力を返すのが慣例的なオプトアウト — 例外なし、特別なシグナルなし。
FILTER は現在のコアコードベースで最も使用頻度の低いパターンです。HookManager の実装は安定しており (ファイルの 143-159 行目)、契約はプラグイン作成者向けに文書化されていますが、コアは文書化済みの page.maillist.show.redirect 例を超えて、本番ホットパスで Hook::filter をまだ呼び出していません。プラグインで合成可能な変換を望むホスト側の新しいホットパスは、BEHAVIOR よりも FILTER に手を伸ばすべきです — チェインセマンティクスは、ほとんどの「プラグインにこれを追加できるようにしたい」状況が必要とするものです。
4 パターンを跨いだコンフリクトセマンティクス
2 つのプラグインが同じフック名をターゲットにする場合、4 つのパターンは異なる反応をします。どの種類のパターンを持っているかを知れば、コンフリクトがどのように見えるか、それがブート時に表面化するかずっと後になるかが正確に分かります。
| パターン | 同じ名前をターゲットにする 2 つのプラグインの動作 | 表面化の仕方 |
| REGISTRY | 両方の寄与が保持され、collect は両方を返します | コンフリクトなし — 設計どおり。順序は登録順。 |
| EVENT | 両方のリスナーが実行され、副作用が合成されます | コンフリクトなし — 設計どおり。順序は登録順。 |
| BEHAVIOR | 2 度目の set 呼び出しが Behavior "{name}" has already been registered で例外を投げます | ブート時例外 — プラグインの ServiceProvider::boot() が失敗し、マスターファイルがエラーを記録、管理画面の Plugins ページが赤いピルを表示します。本番でサイレントなオーバーライドが起きることはありません。 |
| FILTER | 値は登録順に両方のコールバックを通過します | コンフリクトなし — 設計どおり。各コールバックは入力を変更せず返すことでオプトアウトできます。 |
4 つのパターンのうち 3 つがコンフリクトフリーなのは、集約するからです。BEHAVIOR は唯一排他的所有権を持つもので、失敗モードはブート時の硬い例外です — 誤用が動作するインストールと共存できないようにする意図的な設計選択です。
引数バッグの慣習
すべての fire / collect / perform / filter は同じ形を取ります: $name, $value (またはデフォルト), $params。$params 配列は登録済みコールバックに位置的にアンパックされます。チェイン内のすべてのコールバックが同じ引数を受け取ります。
- 引数バッグを小さく安定に保つ。 フックが出荷されると、引数は契約になります — 位置引数を追加すると、固定シグネチャでクロージャをすでにバインドしているすべてのプラグインが壊れます。
- フィールドバッグではなくモデルを渡す。
fire('customer_added', [$customer]) はリスナーに読むフィールドを決めさせます。fire('customer_added', [$customer->email, $customer->uid, ...]) はフックが出荷された時点に存在したフィールドに引数をロックしてしまいます。
- 引数をオーバーロードするのではなく、追加のフックを使う。 スロットがよりリッチなコンテキストを必要とするなら、5 番目のオプション位置引数を追加するのではなく、別のフックキーを登録してください。
参照渡しでの collect の癖
コアの小さな部分は、4 パターンモデルから外れて、collect() を参照渡し引数と共にミューテーションフックとして使っています。filter_aws_ses_dns_records 呼び出し箇所が正規の例です。
// app/Http/Controllers/Refactor/SendingController.php:812
Hook::collect('filter_aws_ses_dns_records', [&$identity, &$dkims, &$spf]);
ホストはプラグインが $dkims と $spf をその場で変更することを期待してフックを発火します。厳密に言えばこれは REGISTRY の誤用です — 真の FILTER チェインが正しい形だったでしょう。PHP のクロージャが参照渡し引数を尊重するため動作しますが、プラグイン作成者はこのスタイルで新しい呼び出し箇所を書くべきではありません。代わりに FILTER (単一値の変換) か REGISTRY (複数の不変寄与) に手を伸ばしてください。
避けるべき 6 つのアンチパターン
以下のパターンはすべて一見正しく見え、微妙に壊れます。それぞれが、本番プラグインですでに見られたバグのクラスに裏付けられています。
1. boot() が必要なフックを register() で登録する
ホストの register() はいかなる Service Provider の boot() よりも前に実行されます。そこで登録されたフックは、他の Provider の register() で配線された依存より前に発火します。症状: 最初のリクエストで Class not found、プラグインコードがメインパスを実行する前に。修正: register() に属するフックは add_translation_file のみ。それ以外はすべて boot() に。
2. 「共有」が目的のときに BEHAVIOR に手を伸ばす
BEHAVIOR は 2 度目の set で例外を投げます。2 つのプラグインが正当に同じポイントに影響を与える必要があるなら、FILTER (合成) か REGISTRY (収集) が正しい形です。修正: フック契約を書き直す — 排他的 callable ではなく、チェインまたはリストを発火する。
3. Service Provider 内に setIfEmpty を置く
setIfEmpty は誰もまだ登録していない場合にのみ有効になります。register() や boot() に置くと、プラグインの set() より前に走り、プラグインをロックアウトする可能性があります。修正: 一致する perform 呼び出しの直上、コントローラーまたはモデル内に setIfEmpty を置き、すべてのプラグインの boot() がすでに終了しているようにしてください。
4. REGISTRY コールバック内で共有状態を変更する
collect はすべてのコールバックを登録順に呼びます。共有キャッシュやセッションに副作用として書き込むコールバックは、フックを非決定的にします — 異なるプラグイン順で 2 回実行すると異なるキャッシュ状態になります。修正: add コールバックを純粋に保ってください。寄与が副作用に依存するなら、EVENT リスナーを別途登録してください。
5. 既存フックに位置引数を追加する
フックが本番に出てしまうと、プラグインは元のアリティでクロージャを既にバインドしています。5 番目の位置引数を追加すると、それを省略したすべてのバインディングが壊れます。修正: 新しいフック名を登録し (customer_added_v2)、移行ウィンドウを受け入れる。あるいは、すでに引数バッグにあるモデルオブジェクトを通じて新しいコンテキストを渡す。
6. 単一の回答に collect() を使う
REGISTRY は配列を返します — 1 つのプラグインだけが登録していても、ホストは [$result] を取得します。それを答えとして扱う ($first = Hook::collect(...)[0]) と、コンフリクトセマンティクスなしに、最初にブートしたプラグインをサイレントに選ぶことになります。修正: ちょうど 1 つの答えが期待されるなら BEHAVIOR を使用してください。
パターンの選び方
判断は通常 4 つの質問に収束します。順番に答えると正しい形が選べます。
- 複数のプラグインが合成すべきか? はいなら、REGISTRY (独立した寄与) または FILTER (チェイン変換)。いいえなら、BEHAVIOR (排他的オーバーライド)。
- ホストは戻り値を必要とするか? いいえなら EVENT (副作用のみ)。はいなら REGISTRY / BEHAVIOR / FILTER。
- 値は複数の手を通過するか? はいなら FILTER (チェイン)。各手が独立して寄与するなら REGISTRY (収集)。
- 2 つのプラグイン間のコンフリクトはバグか? はいなら BEHAVIOR (ブート時の派手な失敗)。いいえなら、コンフリクトフリーなパターン。
迷ったら、ゆるい方のパターンを選んでください。REGISTRY + クリーンアップ用の「帯域外」EVENT は、ほとんどの場合 BEHAVIOR よりも柔軟です — コンフリクトセマンティクスが実運用で BEHAVIOR を殺すものであり、ゆるいパターンは調整なしにプラグインを合成できます。
次に読むべきページ
4 つのパターンを深く把握し、各形を予測可能にするコンフリクトセマンティクスも得ました。この知識を、本物のプラグインが実際に触れる日常使用の面に変える 3 ページ: