チャットボックス、サイドバーグループ、ページカードを Blade を fork せずにマウントする。

ホストアプリケーションはプラグインが描画できる 4 種類のスロットを予約しています。マスター app / admin レイアウトを継承するすべてのページで発火する 3 つのレイアウトレベルスロットに加えて、より細かな注入のためのページ毎スロットです。4 つすべてが REGISTRY パターンを使用します — 登録したプラグインは HTML 文字列 (またはスキップ用の null) を寄与し、ホストは配列をイテレートし、falsy リターンをフィルタし、各フラグメントをエスケープせず出力します。本ページでは契約、args-bag、標準的な acelle/ai 実装、そして一見正しく見えても本番で壊れるアンチパターンを扱います。

なぜ UI 注入が存在するのか

機能を追加するプラグインは、通常その機能をホスト UI のどこかに表面化させる必要があります。素朴な選択肢はそれぞれ別の問題を抱えます。ホストの Blade レイアウトを fork する場合、プラグインはホストアップグレード毎に自前のコピーを保守し続けなければなりません。インストール時にパッチを当てる場合、ホストのソースと本番で動いているものが食い違います。いずれもプラグインとホストをリリース毎に増す保守負担で縛り付けます。

プラグインシステムはこのトレードオフを回避するため、ホストのマスターレイアウト内に名前付きスロットを予約します。各スロットは REGISTRY フックです — 登録したプラグインは HTML 文字列を寄与し、ホストは描画時にそれらを収集し、falsy なコントリビューションを除外し、各フラグメントを登録順に出力します。プラグインはホストの Blade ソースを見ません。ホストはどのプラグインが何を寄与したかを知りません。

3 つのレイアウトスロット

マスター app および admin レイアウトから 3 つの REGISTRY フックが発火します。これらを合わせれば、プラグインが必要とする UI 拡張のほぼすべてをカバーできます — ドキュメント先頭のアセット、body 末尾のウィジェット、管理サイドバーグループです。

フックキー呼び出し箇所Args バッグ用途
layout.head.assets resources/views/refactor/layouts/{app,admin}.blade.php@yield('head') の直前 [$layout, $context] ページ固有コンテンツより前に読み込む必要がある <link> / <style> / <script> タグ — チャットボックス CSS、sparkle ポップオーバースクリプト
layout.body.before_close 同ファイル、</body> の直前 [$layout, $context] ページごとに 1 度マウントされるフローティングウィジェット — チャットボックスバブル、モーダルオーバーレイ、sparkle ポップオーバー
admin.sidebar.groups resources/views/refactor/components/nav/admin-sidebar.blade.php (引数なし) プラグインが寄与する管理サイドバーセクション — 各エントリは <div class="mc-nav-group">...</div> フラグメントとして描画されます

3 つすべてが同じイディオムでホスト側から収集されます。resources/views/refactor/layouts/admin.blade.php に含まれる Blade スニペットを示します。

@foreach (array_filter(\App\Library\Facades\Hook::collect('layout.head.assets', ['admin'])) as $html)
    {!! $html !!}
@endforeach

このスニペットから読み取るべきことが 3 つあります。collect は args バッグ (ここでは ['admin']) を受け取ること、array_filter はすべての null / false / '' コントリビューションを除外すること、そして残った HTML は {!! !!} — エスケープなしで出力されること (既に描画済みの Blade だからです)。

契約 — HTML または null を返す

3 つのレイアウトスロットいずれかに対する REGISTRY コールバックは、次の 2 つのいずれかを返します。

  1. HTML 文字列 — 通常は view('myname::partials.foo')->render() の結果。ホストはこれを {!! !!} でそのまま出力します。
  2. null (または任意の falsy 値 — false''0)。ホストの array_filter が除外します。これはコントリビューションを機能フラグ、プラグインステータス、環境、リクエスト毎のコンテキストでゲートする定石です。

null を返すほうが 登録しない よりも好まれます。プラグインの boot() はプロセスごとに 1 度走ります。寄与するかどうかの判断は boot 時ではなく描画毎に行うべきです。storage/app/plugins/acelle/ai/src/ServiceProvider.php:732aiPluginAvailable() チェックは標準例です — AI モジュールがゲートオフのとき、クロージャは null でショートサーキットし、他のプラグインのコントリビューションには一切触れません。

返される HTML は self-contained でなければなりません。ホストはフラグメントを呼び出し箇所のドキュメントに、追加のエスケープやラップなしで挿入します。フラグメントが依存するもの — CSS、JS、フォントファイル — は、描画時点ですでに読み込まれている必要があります。そのため、layout.body.before_close に加えて layout.head.assets が存在します。head フラグメントが先にロードされ、body フラグメントが最後にマウントされるため、プラグインは必要に応じてアセット登録を両スロットに分割できます。

args-bag — $layout$context

layout.head.assetslayout.body.before_close はどちらも 2 つの位置引数を渡します: $layout (スロットを発火させたマスターレイアウトを識別する文字列 — 'app''admin' など) と $context (レイアウトが任意で公開する表面固有プロパティを運ぶオプション配列)。

// Plugin (in ServiceProvider::boot())
Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
    if (! $this->aiPluginAvailable()) {
        return null;
    }

    return view('ai::partials.head_assets', [
        'layout'  => $layout,
        'context' => $context,
    ])->render();
});

単一の共有フックキーが、すべてのマスターレイアウト — appadmin、メールビルダー、フォームビルダー、オートメーションエディタ — から発火します。プラグインのパーシャルは内部で $layout をディスパッチし、適切なアセットセットやチャットボックス設定を描画します。layout.app.head.assets / layout.admin.head.assets のような別フックはありません。レイアウト名は単に 1 つの共有バッグ内の識別子です。

既存のレイアウトスロットに位置引数を追加すると、元のアリティでクロージャをバインド済みのすべてのプラグインを壊します。新しいコンテキストは $context 配列 (クロージャシグネチャを変えずに拡張可能) か、別のフックキーの背後に置きます。ホスト自身の aiHooks コントリビューションはまさにこれを実践しており、ビルダーとオートメーションエディタは表面プロパティを $context 経由で渡し、プラグインは存在する場合に $context['kind']$context['task'] などを読みます。

実例 — acelle/ai のチャットボックスバブル

レイアウト注入の標準リファレンスは storage/app/plugins/acelle/ai/src/ServiceProvider.php の 678 〜 728 行目にあります。プラグインは 3 つのレイアウトスロットすべてに、同じ aiPluginAvailable() ゲートの背後で寄与します。登録ブロック全体を上記の契約に沿って言い換えると次のとおりです。

// In acelle/ai's ServiceProvider::boot()

private function registerLayoutInjections(): void
{
    Hook::add('layout.head.assets', function ($layout = 'app', array $context = []) {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.head_assets', [
            'layout'  => $layout,
            'context' => $context,
        ])->render();
    });

    Hook::add('layout.body.before_close', function ($layout = 'app', array $context = []) {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.body_assets', [
            'layout'  => $layout,
            'context' => $context,
        ])->render();
    });
}

private function registerAdminSidebarSection(): void
{
    Hook::add('admin.sidebar.groups', function () {
        if (! $this->aiPluginAvailable()) {
            return null;
        }
        return view('ai::partials.admin_sidebar_group')->render();
    });
}

本番ではこれら 3 ブロックから次のことが実現されます: app または admin レイアウトを継承するすべてのページが <head> にチャットボックス CSS / JS を、</body> 直前にチャットボックスバブル HTML を、(管理ページに限り) ホスト組み込みグループの後に描画される「AI」サイドバーグループを得ます。これらを動作させるためにホストの Blade ファイルを 1 つも変更していません — プラグインは共有フックキー経由で寄与し、ホストは到着したものを描画します。

プラグインはコントリビューションを aiPluginAvailable() でゲートします。これは ai_plugin_active() をチェックするヘルパーで、最終的に Plugin::getByName('acelle/ai')->isActive() に解決されます。管理者が Plugins ページからプラグインを無効化すると、次のリクエストですべてのコールバックが null を返し、チャットボックス + sparkle UI が消えます — ルートを再読み込みすることも、登録済みサービスをドロップすることも、キャッシュを無効化することもなく

admin.sidebar.groups は 3 つのレイアウトフックの中で最もシンプルです: 引数なし、コールバックは self-contained な <div class="mc-nav-group">...</div> フラグメントを返します。ホストは組み込みグループ (Customers、Plans、Settings、…) の後、レイアウトの終端より前にこれを描画します。順序は登録順なので、描画位置を勝ち取りたいプラグインは依存関係の後 — boot() の後半 — で登録します。

acelle/ai のサイドバーグループはプラグイン内の resources/views/partials/admin_sidebar_group.blade.php にあり、プランフラグに応じて 3 〜 4 個の子を持つ "AI" グループを描画します。同じパターンは、トップレベルの管理セクションを必要とするあらゆるプラグイン — ロイヤルティポイントプラグイン、決済ゲートウェイプラグイン、地域送信ドライバープラグイン — に通用します。

ページレベルスロット — page.{controller}.{action}.{slot}

レイアウトレベル注入はすべてのページに表示するものを担います。ページレベルスロットは特定の 1 ページに表示するものを担います。命名規約により紐付けが明示化されます。

page.{controller_slug}.{action}.{slot}

例:

  • page.maillist.show.body — メーリングリスト詳細ページの body 内に描画される追加カード
  • page.maillist.verification.body — 検証ステータスブロックの上に描画されるコンテンツ
  • page.campaign.index.sidebar — キャンペーンインデックスページのサイドバーへの追加
  • page.customer.edit.footer — 顧客編集ページのフッターへの追加

ホスト側の呼び出し箇所はレイアウトスロットと同じ形 — collectarray_filter、各フラグメントを {!! !!} で出力します。


@foreach (array_filter(\App\Library\Facades\Hook::collect('page.maillist.show.body', [$list])) as $html)
    {!! $html !!}
@endforeach

プラグインのコントリビューションはレイアウトスロットのものと同様で、スロット固有の args バッグが渡されます。

// Plugin (in ServiceProvider::boot())
Hook::add('page.maillist.show.body', function ($list) {
    $points = LoyaltyPoints::getTotalForList($list);
    return view('loyalty::list_points_card', [
        'list'   => $list,
        'points' => $points,
    ])->render();
});

ページレベルスロットの args バッグには通常、関連するモデル — メーリングリスト、キャンペーン、顧客 — を載せます。これによりプラグインは追加の DB クエリなしで必要な情報を読めます。この規約に従うことで、ホストのモデル進化を通じてフックシグネチャの安定性が保たれます: MailList に新フィールドを追加してもプラグインのフックシグネチャを壊しません。プラグインは常にモデルを受け取るからです。

ページリダイレクト — FILTER バリアント

一部のページフックは REGISTRY ではなく FILTER パターンを使います — 典型例は「コアが描画する前にユーザーをリダイレクトすべきかをプラグインに判断させる」です。契約は次のとおりです。

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

// Plugin (in ServiceProvider::boot())
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"
});

形状は REGISTRY (独立した複数のコントリビューション) ではなく FILTER (値のチェイン変換) です。1 リクエストで起こせるリダイレクトは 1 つだけだからです。クロージャから入力値をそのまま返すのが慣例的なオプトアウトです — チェイン内の次のプラグインは前のプラグインが決定した値を見ます。フィルタチェイン完了後にコントローラが if ($redirect) をチェックするため、最初の非 null 値が勝ちます。

このパターンは athena/evs がメーリングリスト検証表面を引き取る必要があるとき、ユーザーを自プラグインの検証ページへルーティングするために使用しています。FILTER のフル仕様は Hook システムディープダイブ にあります。実務的な要点は、ページレベルのリダイレクトは FILTER を、ページレベルの描画は REGISTRY を使う、という点です。

アセット publish — CSS / JS / 画像をプラグインに同梱する

レイアウトスロットはプラグインが どこに 描画するかを扱います。アセット publish は描画された HTML が 何を 参照できるかを扱います。CSS、JavaScript、フォント、画像を出荷するプラグインは、ホストが認識する 'plugin' タグを付けて、Laravel 標準の publishes() API を使います。

// In ServiceProvider::boot()
$this->publishes([
    __DIR__ . '/../resources/assets' => public_path('plugins/acmecorp/loyalty'),
], 'plugin');

Plugin::register() ごとに、ホストは artisan vendor:publish --tag=plugin --force を実行し、プラグインの resources/assets/ ツリーを public/plugins/{vendor}/{name}/ にコピーします。プラグイン自身のパーシャルはこのパス経由でアセットを参照します。


<link rel="stylesheet" href="{{ asset('plugins/acmecorp/loyalty/styles.css') }}">

routes.php のアイコン配信ルート (plugin.{vendor}.{name}.icon という名前付きルート) は代替手段です。public/ に静的 SVG を publish する代わりに、プラグインは storage/app/plugins/{vendor}/{name}/icon.svg から直接アイコンをストリーミングする HTTP ルートを公開できます。トレードオフ: publish パスは高速 (CDN キャッシュ可能) ですが、インストール毎の publish が必要。ルートパスは self-contained ですが、リクエスト毎に Laravel ブートのコストがかかります。

アンチパターン

1. 文字列ではなく Blade View オブジェクトを返す

ホストはクロージャの戻り値を {!! !!} で出力します。view('foo') を返すとオブジェクトの __toString が出力され、多くの場合は動作しますが、クロージャ内で描画エラーを優雅に処理する機会を失います。修正: acelle/ai の登録と同様に、必ず ->render() を呼び、結果の文字列を返します。

2. ゲート用 null ブランチを忘れる

常に HTML を返す REGISTRY コールバックは、プラグインがアクティブかどうかにかかわらず寄与し続けます — autoloadWithoutDbQuery() は非アクティブプラグインもロードするからです (プラグインアーキテクチャ § なぜ非アクティブプラグインがアプリに影響するのか を参照)。修正: 各クロージャの先頭で Plugin::enabled('myvendor/myplugin') または機能フラグヘルパーでガードし、ゲートオフ時は null を返します。

3. ホストが渡していない追加引数で collect() を呼ぶ

プラグインが自分で Hook::collect を呼ぶことはありません — ホストが呼びます。プラグインがカスタム判定にレイアウト名を必要とする場合は、クロージャの最初の引数から読み取ります。プラグイン内から Hook::collect でレイアウトスロットを呼び出すと、他のすべてのプラグインのコールバックが余分に 1 回走ります。修正: クロージャは必要な引数をすべて受け取ります。登録済みハンドラの内側で collect を再呼び出ししないでください。

4. クロージャ内でブロッキング処理を描画する

クロージャはページ描画ごとに 1 度走ります — その内側で Stripe API 呼び出しや 200ms の DB クエリを行うと、そのコストがリクエスト毎に加わります。修正: 事前計算、キャッシュ、または非同期ローダーに移動します。フラグメントは <div data-async-loader> プレースホルダを返し、プラグインの JS がバックグラウンドフェッチからハイドレートできます。

5. REGISTRY コールバック内の副作用

collect はすべてのコールバックを登録順に呼びます。副作用としてセッション、キャッシュ、ログに書き込むコールバックはフックを非決定的にします。2 つのプラグインが同じキーをミューテートするレースが発生し得ます。修正: add コールバックは純粋に保ちます — 値を 寄与する ためにあり、処理を行うためのものではありません。副作用が必要なら、別のフックに EVENT リスナーを登録します。

6. CSS スコープの衝突

2 つのプラグインが両方とも layout.head.assets 経由で CSS を注入し、両方とも .mc-popover というクラスを定義する。プラグインのロード順が CSS の到着順となり、後勝ちになります。修正: プラグイン CSS クラスを名前空間化する (.acmecorp-loyalty-popover)、またはラッパー要素の属性セレクタでスコープします。ホストはプラグイン CSS を取り締まりません — プラグイン作者の規律の問題です。

次のステップ

UI 注入は新規プラグイン作者から最も質問されるテーマですが、機能全体を担うことは稀です。同じツールキットを発展させる 3 ページを紹介します。