以降のドキュメントが前提とするメンタルモデル。

このコードベースにおけるプラグインは小さな Laravel パッケージですが、ホストアプリケーションがそれをロードする方法は Composer が通常パッケージをロードする方法とは異なります。vendor/ へのインストール手順も、composer.lock へのエントリも、autoload の再生成もありません。すべてのプラグインはランタイムに、単一の JSON マスターファイルから、ホストが AppServiceProvider::boot() 内でインスタンス化する新しい Composer\Autoload\ClassLoader によって登録されます。この全体像を掴めば、残りの開発者ドキュメント — フック、送信ドライバー、決済ゲートウェイ、UI 注入、ライフサイクル — はその周辺にきれいに収まります。

ここでのプラグインとは

プラグインは自己完結した Laravel パッケージで、ホストの AcelleMail インストール内の storage/app/plugins/{vendor}/{name}/ に配置されます。独自の composer.json、独自の PSR-4 名前空間、独自の Service Provider、独自のルート、ビュー、Migration、翻訳を持ちます。構造は小さな Laravel アプリケーションとまったく同じです — ただし 1 つの決定的な違いがあります。

ホストアプリケーションは、プラグインをルートの Composer autoloader 経由でインストールしませんcomposer require 手順も、vendor/{vendor}/{name}/ ディレクトリも、composer.lock へのエントリもありません。代わりに、アプリケーションが起動するたびに、自身で次の処理を行います。

  1. 各プラグインの composer.json を読み込みます。
  2. そこで宣言された PSR-4 名前空間を、新しい Composer\Autoload\ClassLoader インスタンスに登録します。
  3. extra.laravel.providers に列挙された Service Provider に対して App::register(...) を呼び出します。

この設計は意図的です。プラグインを Composer インストール済みパッケージとして扱うと、ホストアプリケーションの composer.json が常に変動するターゲットになります — インストール、無効化、アップグレードのたびに lockfile が変化してしまいます。ランタイムローダーはホストの依存グラフを安定に保ちます。プラグインは自身のメタデータと共に出荷され、ホストは vendor/ に触れることなくそれらをスキャン、無視、並べ替えできます。

システム全体を支配する 5 つのファイル

プラグインライフサイクルにおけるほぼすべての挙動は、ホストアプリケーション内の 5 つのファイルに実装されています。本ドキュメントの内容を確認する最速の方法は、これらのソースを読むことです。

ファイル責務
app/Console/Commands/InitPlugin.phpphp artisan plugin:init の CLI エントリポイント。Plugin::init($name) の薄いラッパー。
app/Model/Plugin.phpライフサイクル全体: scaffold、register、load、activate、disable、delete、加えてマスターファイル機構。
app/Library/HookManager.phpプラグインがコアの動作を拡張するための注入プリミティブ — REGISTRY、EVENT、BEHAVIOR、FILTER。約 160 行、依存ゼロ。
app/Providers/AppServiceProvider.phpブート時のプラグイン autoload と翻訳登録。プラグインを実行中のアプリケーションに接続する唯一の呼び出し箇所。
app/Model/Language.phpプラグインの翻訳ファイルを storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ へ実体化します。管理者が Languages UI からプラグインのソースに触れずに翻訳を編集できるようにする間接化です。

合計でホスト側コードは 3,000 行未満です。プラグインシステムは意図的に小さく作られており — プラグインに課される制約はすべてこの 5 ファイルのいずれかから来るもので、他には見るべき場所はありません。

ブートとロードのフロー

すべてのリクエスト、Queue Worker、scheduler tick、Artisan コマンドは同じブートシーケンスを通ります。そのプラグイン関連部分は次のとおりです。

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.

このシーケンスにおける 2 つの実装上の詳細が、プラグイン作成者に対して非常に大きな影響を持ちます。

1. ブート時の検出は決してデータベースを参照しない

ロードするプラグインのリストは storage/app/plugins/index.json から取得され、plugins テーブルからは取得されません。Service Provider はデータベースを安全にクエリすることはできません — AppServiceProvider::boot() が実行される時点では、接続が存在しない可能性 (artisan db:create のような CLI コマンド) や、スキーマが Migration されていない可能性 (CI テストセットアップ) があります。ブート時のレジストリを JSON ファイルに保管することで、この問題全体を回避できます。

DB テーブルは依然として存在します。JSON ファイルと同じ status に加え、titledescriptionversion といったユーザー向けメタデータを保持しています。管理画面の Plugins ページは DB から読み、ブートローダーは JSON から読みます。両者は Plugin::register()activate()disable() によって同期されており — ステータス変更は常に両ストアへ書き込まれます。

2. autoloadWithoutDbQuery() は現状、インデックス内のすべてのプラグインをロードする — 非アクティブなものを含めて

現在の実装は index.json の各エントリを反復し、status に関係なく loadPluginByName を呼び出します。理由は実用的です。非アクティブなプラグインであってもルートを登録する必要があり (管理者が「無効化」をクリックして即座にリロードしなくても管理画面が動き続けるため)、翻訳も利用可能である必要があります (dump されたクローンが古くならないため)。

その結果、AcelleMail のプラグインシステムにおける 「inactive」「unloaded」 と同じではありません。次のセクションでこの区別を厳密に説明します。

composer.json の契約

プラグインの composer.json は単なるメタデータではなく、ローダーが依存するランタイム契約です。重要なキーは以下のとおりです。

キー用途
nameプラグインの正規 ID。storage/app/plugins/ 配下のディレクトリ名と完全に一致する必要があります。一致しない場合 Plugin::register() が例外を投げます。
autoload.psr-4プラグインの名前空間プレフィックスを src/ にマップします。必須 — これがなければ loadPluginByName() が例外を投げ、プラグインはブートできません。
extra.laravel.providers完全修飾クラス名の配列。ローダーは各エントリに対して App::register() を呼び出します。プラグインがルート、ビュー、フックなどを登録したい場合は必須です。
extra.setting-route管理画面の Plugins ページがプラグインの「Settings」ボタンとしてリンクする controller@method。任意 — 設定不要なプラグインは省略できます。
title, description, version管理画面の Plugins 一覧に表示されます。title は必須、その他はデフォルトにフォールバックします。

autoload マッピングはインストール時ではなくランタイムに登録されます。 プラグインの PSR-4 マップを編集した後に composer dump-autoload を実行する必要はありません — ホストはリクエストごとに新しい ClassLoader をインスタンス化し、ファイルを再読み込みします。これはまた、プラグインの名前空間を変更するには検索置換とホストへの 1 リクエストだけで済む理由でもあります。

マスターファイル (storage/app/plugins/index.json)

マスターファイルはプラグイン名をキーとしたフラットな JSON オブジェクトです。各エントリは最低限 status を保持し、直近のブート試行が失敗した場合はオプションの error 文字列も保持します。典型的なファイルは次のような形です。

{
  "acelle/ai":      { "status": "active" },
  "acmecorp/loyalty": { "status": "inactive" },
  "broken/sample":   { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}

ホスト側の 3 つのメソッドがこのファイルを所有します。ステータス変更はすべていずれかを経由します。

  • Plugin::updatePluginMasterFile($name, $params) — 単一プラグインのエントリをマージ書き込みします。第 2 引数に null を渡すとエントリ全体を削除します (delete パス)。
  • Plugin::resetPluginMasterFile()Plugin::all() を反復してファイルをゼロから再構築します。JSON が破損した、または DB と同期がずれた際のリカバリーに使用します。
  • Plugin::getErroredPluginNames() — すべてのエントリを読み、error が空でない名前を返します。管理画面の Plugins 一覧はこれを使って壊れたプラグインを下部に押しやり、赤いエラーピルを表示します。

error キーは、autoloadWithoutDbQuery()loadPluginByName() 呼び出しを try/catch で包み、その呼び出しが例外を投げたときにセットされます。例外メッセージが記録されるため、管理画面 UI は失敗を再実行することなく表示できます。クリーンなプラグインを再有効化すると、フィールドは自動的にクリアされます。

マスターファイルはブート時の唯一の真実のソースです。動かないプラグインから復旧する必要がある場合 (管理画面 UI がダウンしている、データベースがオフライン)、storage/app/plugins/index.json を直接編集してください。次のリクエストが更新後の状態を読み込み、それに応じて動作します。DB の行は長期的なメタデータ、JSON ファイルはランタイムのレジストリです。

register() と boot() のタイミング

Laravel はまずすべての Service Provider の register() メソッドを登録順に実行してから、boot() を呼び出します。これは Laravel の周知の挙動ですが — プラグインシステムにおいて直接的な帰結を持ちます。

register() に置くもの

  • 定数とバインディング — ホスト自身の boot() が実行される前に存在している必要があるもの。
  • add_translation_file フック — これだけ。ホストの AppServiceProvider::boot() は自身の boot フェーズで Hook::collect('add_translation_file') を呼び出します。プラグインの boot() が実行される頃には、そのループはすでに終了しています。プラグインが翻訳エントリを boot() で登録すると、それは決して拾われず、trans('myname::messages.intro') はリテラルキーを返します。

boot() に置くもの

  • ルートとビュー$this->loadRoutesFrom(...)$this->loadViewsFrom(...)
  • アセットの publish$this->publishes([...], 'plugin')
  • ライフサイクル Event リスナーHook::on('activate_plugin_{name}', ...)Hook::on('delete_plugin_{name}', ...)
  • アイコン URLHook::set('icon_url_{vendor}/{name}', ...)
  • その他すべてのフック — REGISTRY の add、EVENT の on、BEHAVIOR の set、FILTER の modify。コンテナバインディング、Config、他プラグインに依存するものすべて。

プラグインの boot()$this->loadTranslationsFrom(...) を呼び出してはいけません。 ホストはすでに add_translation_file フックを通じて名前空間を storage/app/data/plugins/... 配下の dump されたランタイムファイルに向けて配線しています。プラグインの boot() から 2 度目の loadTranslationsFrom を実行すると、ホストのヒントを上書きして名前空間を resources/lang/... 配下のマスターファイルに再ポイントしてしまいます。目に見える症状は、Languages UI での管理者編集がランタイムで反映されなくなることです — dump されたクローンがゾンビファイルになります。フックのみを使用してください。

非アクティブなプラグインがアプリに影響を与え続ける理由

ブート時の autoloadWithoutDbQuery() 呼び出しは、ステータスに関わらず index.json 内のすべてのプラグインをロードします。そのため、「非アクティブ」なプラグインも以下のすべてがホストに登録されています。

  • ルート — boot() 内の $this->loadRoutesFrom(...) で宣言。
  • ビュー — $this->loadViewsFrom(...) で宣言。
  • Middleware エイリアス — 標準の Laravel API で登録。
  • フックリスナー — Hook::addHook::onHook::modifyHook::set はすべて発火します。
  • UI フラグメント — layout.head.assetslayout.body.before_closeadmin.sidebar.groups、ページスロット REGISTRY フックを通じて寄与されるものはすべて表示され続けます。

アクティベーションが実際に追加するのは、プラグイン作成者が activate_plugin_{vendor}/{name} に配線したものだけです。スケルトンのリスナーは Migration を実行します。「アクティブな時だけルートを登録する」「非アクティブの時はルートを削除する」といった暗黙の手順はありません — ルートはアプリケーションがブートした瞬間に登録されています。

管理者がプラグインを無効化したときに機能が真に消える必要があるなら、プラグイン作成者はそれを明示的にガードしなければなりません。慣例的なパターンは storage/app/plugins/acelle/console にあります: ルートは常にロードされますが、console.active という名前のルート Middleware が Plugin::getByName('acelle/console')->isActive() が false を返すと 404 で中断します。「無効化 = 到達不能」を意味すべき場合はこのパターンをコピーしてください。

UI フックにも同じことが当てはまります。layout.body.before_close を通じて注入されるチャットボックスのバブルがプラグイン非アクティブ時に隠れるべきなら、クロージャ本体はまず Plugin::enabled('myvendor/myplugin') をチェックし、false の場合は null を返さなければなりません。ホストは falsy な戻り値をレンダリング前に自動的にフィルタリングします。

ライフサイクル: register / activate / disable / delete

4 つの状態、4 つのホスト側メソッド。それぞれが何を変えて何を変えないかについて厳密です。

Register / install

Plugin::register($name) がエントリポイントです — plugin:init の末尾、および管理画面 UI からのアップロード成功時に自動的に呼ばれます。5 つのステップは以下のとおりです。

  1. composer.json を読み、title / description / version をモデルにコピーします。
  2. plugins に行を挿入または更新し、status = inactive とします。
  3. storage/app/plugins/index.json{ "name": { "status": "inactive" } } を書き込みます。
  4. Plugin::load($withServiceProvider = true) を呼びます — PSR-4 プレフィックスを登録し、Service Provider を即座にブートするので、ルート / ビュー / フックは現在のプロセスでライブになります。
  5. Language::dump() を呼んで翻訳ファイルを実体化し、続けて vendor:publish --tag=plugin --force を実行してバンドルされたアセットを public/plugins/... にコピーします。

register 後、プラグインはインストール済みかつロード済みです。唯一足りないのは、プラグインが activate イベントに配線したもの — 典型的には Migration の実行です。

Activate

$plugin->activate() は管理画面 UI の「Activate」ボタン (およびモデルを直接呼ぶテスト / Seeder) から呼ばれます。順番に 4 つのことを行います。

  1. Hook::fire('activate_plugin_'.$name) を発火します。スケルトンのリスナーは storage/app/plugins/{vendor}/{name}/database/migrations に対して artisan migrate を実行します。他のプラグインも追加のリスナーを登録できます — REGISTRY 動作なので、すべてのリスナーが発火します。
  2. プラグインの composer.json をホストの必須キー一覧 (nameversionapp_version) に対して再検証します。
  3. DB の statusactive にセットします。
  4. マスターファイルを更新します: { "status": "active", "error": null } — 以前のブートエラーをクリアします。

Disable

$plugin->disable() は以下だけを行います:

  • DB の statusinactive にセットします。
  • マスターファイルを新しいステータスで更新し、記録済みの error をクリアします。

ルート、ビュー、Service Provider、フックリスナー、その他ブート時に登録されたものをアンロードすることはしません。ホストには「Service Provider を登録解除する」という概念はなく — Laravel 自体がそれをサポートしていません。Disable はステータスのフリップであり、アンロードではありません。

Delete

$plugin->deleteAndCleanup($keepData = false) は完全な撤去を実行します:

  1. Hook::fire('delete_plugin_'.$name, [$keepData]) を発火します。スケルトンのリスナーは migrate:rollback を実行します。$keepData = true は、管理者が保持したいデータを所有するプラグインに対してこれをスキップできます。
  2. storage/app/plugins/... 配下のプラグインディレクトリを再帰的に削除します。
  3. plugins DB テーブルから行を削除します。
  4. マスターファイルからエントリを削除します。

次のリクエストが新しいプロセスをブートするまでは、プラグインの Service Provider はメモリ上にロードされたままです。次のリクエストは (縮小された) マスターファイルを読み、プラグインをロードせず、プロセス内の状態はリクエストライフサイクルとともに破棄されます。

2 つの注入レイヤー

プラグインはホストアプリケーションに 2 つの並列レイヤーを通じて影響を与えます。両者を区別することで、残りのドキュメントがコードと明確に対応します。

レイヤー 1 — Laravel 登録

Service Provider を通じて、プラグインは標準の Laravel コンテナ API を使用してアプリケーションを拡張します。

  • $this->loadRoutesFrom(__DIR__ . '/../routes.php') — プラグインの HTTP 面を追加します。
  • $this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — Blade ビューを myname::view 名前空間で公開します。
  • $this->publishes([...], 'plugin') — バンドルされたアセットをインストール時にホストの public/plugins/{vendor}/{name}/ にコピーします。
  • Middleware エイリアス、コンテナバインディング、コンソールコマンド、スケジュールタスク、Queue リスナー — Laravel 自身がサポートするものすべて。

レイヤー 2 — フックベースの注入

ホストは慎重に選ばれた拡張ポイントで App\Library\HookManager プリミティブを呼び出します。プラグインはそれらのポイントに対してリスナーを登録して参加します。パターンは正確に 4 つです: REGISTRY、EVENT、BEHAVIOR、FILTER。次の深掘り — Hook system — でそれぞれを完全に解説します。

今知っておくべき 2 つのこと: (1) ホストが発火するすべてのフックは安定した契約です — 公開後、リリース間で名前とシグネチャは変わりません。(2) BEHAVIOR は排他的です — 2 つのプラグインが同じ名前を Hook::set しようとすると、2 度目の呼び出しが即座に例外を投げます。サイレントなオーバーライドはなく、コンフリクトは本番ではなくブート時に表面化します。

このコードベースには、UI を拡張するほぼすべてのプラグインが使う 3 つのレイアウトレベル REGISTRY フックが同梱されています。

フックキー発火箇所用途
layout.head.assetsresources/views/refactor/layouts/{app,admin}.blade.php@yield('head') の前ページコンテンツより前にロードする必要のある CSS / JS (チャットボックススタイル、sparkle popover スクリプト)
layout.body.before_close同じレイアウト、</body> の直前フローティングウィジェット — チャットボックスバブル、モーダル、sparkle popover
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpプラグインが寄与する管理サイドバーセクション

3 つすべてが同じイディオムに従います: 各コールバックは描画済み HTML または null を返し、ホストは array_filter で反復し、各フラグメントを {!! !!} で出力します。例外を投げずに、機能フラグやプラグインのステータスで寄与をゲートする慣例的な方法が null を返すことです。

ランタイムでの翻訳フロー

プラグインの翻訳は、プラグインのソース resources/lang/ フォルダから直接配信されるわけではありません。フローは間接的で、その間接性こそが、管理者がプラグインのソースファイルにコミットすることなくホストの Languages UI を通じて翻訳を編集できるようにしています。検証済みのシーケンスは次のとおりです。

  1. プラグインの register()storage/app/data/plugins/{vendor}/{name}/lang/ を指す Hook::add('add_translation_file', ...) エントリを寄与します。
  2. ホストの AppServiceProvider::boot() がそのようなエントリをすべて収集し、それぞれに対して $this->loadTranslationsFrom() を呼び出します。
  3. すべての Plugin::register() で、ホストは Language::dump() を呼びます。
  4. Language::dump() はプラグインのマスターファイル resources/lang/en/messages.php を読み、サポートされる各ロケールについて storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php にコピーします。
  5. Languages 管理 UI は dump されたランタイムファイルを編集します。プラグインのソースマスターファイルはそのまま残ります。

覚えておくべき 2 つのパス:

  • マスターファイル (ソースで編集する): storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
  • ランタイムファイル (自動生成、アプリが実際に読むもの): storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php

マスターファイルを編集したら php artisan translation:upgrade を実行して、マスターをすべてのロケールのランタイムファイルに再同期してください (管理者が Languages UI で編集した翻訳は保持されます)。完全なメカニクス — マスター対ランタイム、upgrade セマンティクス、ロケールごとのフォールバック — は Translations で専用の深掘りがあります。

プラグイン作成者への含意

上記のアーキテクチャから 5 つのルールが導かれます。これらを内面化すれば、残りのドキュメントにおける表面的な複雑さのほとんどは、このリストへの照合に変わります。

  1. boot() を登録フェーズとして扱う。 ルート、ビュー、フック、ライフサイクルリスナー — ほぼすべてがここに入ります。register() に入る唯一のものは add_translation_file フックです (ホストがプラグインの boot() が実行される前にそれを収集するため)。
  2. 非アクティブはアンロードを意味しない。 ブート時に登録したものは、active / inactive ステータスに関係なくライブです。無効化時に機能が真に消える必要があるなら、フッククロージャ内でルート Middleware または Plugin::enabled(...) チェックで明示的にゲートしてください。
  3. 翻訳はマスターファイル経由で編集し、loadTranslationsFrom() を直接呼ばない。 storage/app/data/plugins/... 配下の dump されたクローンがランタイムで読まれるものです。名前空間を自分でマスターディレクトリに向けると、ホストのヒントを上書きし Languages UI が壊れます。
  4. composer.json を薄く安定に保つ。 ランタイムローダーはリクエストごとに読みます。autoload.psr-4extra.laravel.providersnametitle がホストが実際に使うキーです。追加のキーを足しても問題はありませんが、何もしません。
  5. 4 つのフックパターンが唯一の契約。 コアクラスを「import」して拡張したいと思ったら — 一旦止まってください。プラグイン契約は一方向です: コアがフックを宣言し、プラグインが反応します。必要な拡張ポイントがまだフックとして存在しないなら、正しい行動はプラグインのコントローラーから use Acelle\Model\Customer することではなく、ホストに issue を立てることです。

次に読むべきページ

これでアーキテクチャは把握できました。このメンタルモデルを日常使用の API に変える 2 つのページ:

  • Hook system — コアから grep した実際の呼び出し箇所と共に 4 つのパターンを深掘り。コンフリクトセマンティクス、どのパターンをいつ使うか、正しく見えても本番で壊れるアンチパターン。
  • UI 注入 — 上記のレイアウトレベルフックに加え、Blade を 1 つも fork せずに既存ページにカードを注入できる page.{controller}.{action}.{slot} 契約。

本物の機能プラグインを出荷する準備ができたら、実践例は 送信ドライバー (Postal MTA の end-to-end) と 決済ゲートウェイ (地域ゲートウェイとしての Paddle) です。完全な読解演習として、Aurius showcase は正規の複雑なプラグインを通します: 8 つのモデル、14 の Migration、18 のロケール、本番で使われるすべてのフック面。