前提条件
このコードベースのプラグインは小さな Laravel パッケージです。scaffold する前に、拡張するホスト AcelleMail インストールがすでに動作していること、ローカルマシンに動作する PHP ツールチェーンがあることを確認してください。以下の CLI コマンドはアプリケーションルート (artisan ファイルを含むディレクトリ) にいることを前提としています。
ホストアプリケーション
- AcelleMail v4.x がインストールされリクエストを処理していること。プラグインローダーは
App\Providers\AppServiceProvider の一部です — 古い 3.x ビルドには Plugin::autoloadWithoutDbQuery() がありません。
- アプリケーションルートに到達可能な Queue Worker、scheduler、または Web リクエスト — ローダーはオンデマンドではなくブート時に走ります。
storage/app/plugins/ への書き込みアクセス。Artisan コマンドは vendor/ ではなくここに scaffold を書きます。
おさらいすべき PHP の知識
プラグインシステムはひと握りの PHP と Laravel の基本に大きく依存しています。これらが錆びついていると感じるなら、scaffold する前に該当ドキュメントを軽く読んでください — composer.json に間違った名前空間を宣言したプラグインをデバッグするのは、最初から正しくするよりずっと難しいです。
- PSR-4 autoloading。 プラグインの
composer.json は名前空間プレフィックスを src/ ディレクトリにマップします。AcelleMail はそのマッピングをブート時に新しい Composer\Autoload\ClassLoader に登録します — つまり、すべての PHP ファイルの名前空間宣言は、大文字小文字を含めて composer.json マッピングと厳密に一致する必要があります。
- クロージャと
use キーワード。 ほぼすべてのフックリスナーはクロージャです。クロージャが外部変数を必要とするとき、明示的に捕捉する必要があります。これを忘れることが、プラグインコードでの undefined variable エラーの最も一般的な原因です。
- Service Provider 上の
register() と boot()。 Laravel はまずすべての Provider の register()、次にすべての Provider の boot() を実行します。register() に列挙されたフックは依存が準備される前に走る可能性があり、boot() に列挙されたフックは翻訳コレクタには遅すぎる可能性があります。両方とも実在する地雷です — 最初の日の 7 つのエラー を参照。
- Eloquent、Blade、Routes、Facade。 プラグイン Migration は標準の
Schema ビルダーを使い、プラグインビューは通常の Blade ファイルで、プラグインルートは Route::group(...) を使います。プラグインに特殊なものはありません — 生成されたファイルは普通の Laravel です。
プラグインを Packagist に公開したり、プラグインフォルダ内で composer install を実行したり、ホストのルート composer.json に何かを登録したりする必要はありません。ランタイムローダーがすべてのステップを処理します。
命名ルール — 一度読めば 1 時間節約できます
すべてのプラグインは {vendor}/{name} 形式のアイデンティティを持ちます — 例えば Aurius、aix/sample、athena/evs。このアイデンティティはデータベース plugins テーブル、storage/app/plugins/ ディレクトリ、storage/app/plugins/index.json マスターファイル、そしてライフサイクルフック名 (activate_plugin_{vendor}/{name}、delete_plugin_{vendor}/{name}、icon_url_{vendor}/{name}) における正規キーです。
App\Model\Plugin::init() のバリデーターは小さな保守的なルールセットを強制します (正規 regex: ^[a-z0-9]+\/[a-z0-9]+$ 各サイド min:2 max:32):
- 小文字と数字のみ。 アンダースコア、ハイフン、大文字は不可。アンダースコアを許可していた以前のガイダンスは置き換えられました — 古い README で
my_plugin を見ても、それはもう有効ではありません。
- 各サイド 2〜32 文字。
a/sample は失敗 (vendor が短すぎる)、team/x は失敗 (name が短すぎる)。
- スラッシュは正確に 1 つ。 vendor と name。ネストなし。
保守的な交差ルールは 2026-04 のクリーンアップから来ており、Plugin::init() と Plugin::getStoragePathByName() を整合させました。両バリデーターは今や同じ regex に合意しています — 名前が clean に scaffold してその後ロードに失敗する方法はもうありません。
vendor セグメントは慎重に選んでください。 vendor はすべての名前空間、プラグインの routes.php のすべての URL プレフィックス、プラグインが発行するすべての翻訳キーの一部です。後で改名するということは、すべてのファイルにわたる検索置換を意味します。acmecorp/loyalty は曖昧ではありません。x/loyalty は無効 (vendor が短すぎる)、acmecorp/loyaltypoints は問題ありません。
scaffold コマンド
アプリケーションルートから実行:
php artisan plugin:init {vendor}/{name}
実例として acmecorp/loyalty を使います — このページの残りはその名前を前提とします。コマンドを実行するときはご自身のものに置き換えてください。
$ 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
成功メッセージは App\Console\Commands\InitPlugin が出力します。これはモデルレベルメソッド App\Model\Plugin::init($name) の薄いラッパーです。そのメソッドがこのページの残りで説明することをすべて行います — バリデーション、scaffold コピー、Twig レンダリング、ファイル改名、続いて DB 行を挿入し Service Provider をブートする Plugin::register($name) へのチェイン呼び出し。
プロンプトが戻る頃には、プラグインはすでに非アクティブパッケージとして実行中のアプリケーションにロードされています。routes.php で宣言されたルートは到達可能、ビューはレンダリング可能、Service Provider が登録したフックはライブです。アクティベーションが追加する唯一のものは、プラグイン作成者が activate_plugin_{vendor}/{name} イベントに配線したもの — 典型的には Migration 実行です。
生成されたもの
Artisan コマンドは storage/app/plugins/{vendor}/{name}/ 配下に小さなスターターファイル一式を書き込み、その中の Twig プレースホルダをレンダリングし、プレースホルダ Migration を改名します。ファイルの正確なリストは Plugin::init() にハードコードされています — 内容レンダリングされる 8 ファイルと数個の静的アセット。これらのファイルは特別なものではありません。普通の Laravel で、削除、改名、拡張は自由です。
コマンド完了後のディスク上のディレクトリツリー:
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
8 ファイルの俯瞰
| ファイル | 用途 |
composer.json | ランタイム契約: name、autoload.psr-4、extra.laravel.providers が必須。これらがないとローダーは名前空間を登録できず Provider をブートできません。 |
src/ServiceProvider.php | Laravel が見る単一のエントリポイント。register() で翻訳を登録し、boot() でルート、ビュー、ライフサイクルフック、アイコン URL を登録します。 |
src/Controllers/DashboardController.php | 使い捨てのサンプル。同梱の index.blade.php ビューを返します。自由に置き換えてください。 |
src/Models/Setting.php | プラグインの最初の Migration にバインドされた Eloquent モデル。テーブル名は {vendor}_{name}_settings として名前空間化されているので、プラグインは同じ DB 上で衝突できません。 |
routes.php | Service Provider からロードされます。管理画面の Plugins ページが使うアイコン配信ルートと、サンプルの plugins/{vendor}/{name} ダッシュボードルートの両方を宣言します。 |
resources/views/index.blade.php | DashboardController がレンダリングする Hello World ビュー。実 UI に置き換えてください。 |
resources/lang/en/messages.php | マスター翻訳ファイル。Language::dump() がランタイムにこれを storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ へコピーします — dump されたファイルがアプリが実際に読むものです。 |
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.php | 最初の Migration。プラグインが有効化されたときのみ実行され、削除時にロールバックします。ファイル名は Twig 自身がプレースホルダをレンダリングしない唯一のものです — Plugin::init() が別の str_replace パスで改名します。 |
本物の出荷プラグインはこの最小面を超えて成長します。コードベース内の正規リファレンスは storage/app/plugins/Aurius/ です — 8 つの Eloquent モデル、14 の Migration、18 のロケール、60 以上のビュー、管理サイドバーグループ、チャットボックス UI バブル、独自のキューバウンドジョブ。Hello World スケルトンは意図的に最小限なので、すべてのサブシステムを一度に学ぶことなく、ピースを 1 つずつ置き換えられます。追加のコントローラーは src/Controllers/、追加のモデルは src/Models/、追加のサービスは src/Services/、追加の Migration は database/migrations/ 配下に置きます。
裏で Plugin::register() が行ったこと
出力行は created & loaded と言っており、それは正確です。ファイルコピーと成功メッセージ表示の間に、Plugin::init() は Plugin::register($name) を呼び、5 つの個別ステップを実行します:
- プラグインの
composer.json を読みます。 name フィールドはディレクトリと厳密に一致する必要があります (acmecorp/loyalty) — 不一致は composer name in composer.json is expected to be … 例外を投げます。
plugins データベーステーブルの行を作成または更新します。 title、description、version は composer メタデータから引かれます。ステータスは inactive にセットされます。
- マスターファイルを書きます。
storage/app/plugins/index.json はブート時レジストリです — AppServiceProvider::boot() がリクエストごとにこのファイルを読み、データベースに触れずにどのプラグインを autoload するかを決定します。アクティベーションと無効化は後で同じファイルを変更します。
- Service Provider を即座にロードします。 プラグインの
boot() が現在のプロセスで走るので、登録するルート / ビュー / フックは次のリクエストの前にライブになります。
- 翻訳ファイルを実体化します。
Language::dump() がすべての add_translation_file フックエントリを読み、マスターファイルを storage/app/data/plugins/... へコピーし、最後に vendor:publish --tag=plugin --force を実行してバンドルされたアセットを public/plugins/... 配下に置きます。
覚えておくべきメンタルモデル: 「インストール済み」はすでに「ロード済み」を意味する。アクティベーションは純粋にステータスフリップと、プラグイン作成者がアクティベーションイベントで発火するように配線したもののみです。アクティベーションがトリガーするルート登録という別ステップはありません — ルートは plugin:init が終わった瞬間に登録されています。
非アクティブなプラグインも依然としてロードされます。 Plugin::autoloadWithoutDbQuery() の現在の実装は、ステータスに関わらず index.json に列挙されたすべてのプラグインをロードします。管理者がプラグインを無効化したときに機能が真に消える必要があるなら、プラグイン作成者はそれを明示的にガードしなければなりません — Plugin::getByName($name)->isActive() をチェックして 404 で中断するルート Middleware が慣例パターンです。コアプラットフォーム自身の admin-console プラグインが正規の例です。
プラグインを有効化
プラグインが scaffold され非アクティブになったら、次のステップは active としてマークし、activate_plugin_{vendor}/{name} リスナーが Migration を実行するようにすることです。2 つのパス:
管理画面 UI から
管理者としてサインインし、/rui/admin/plugins を開き、Loyalty エントリを見つけて Activate をクリックします。ページは routes.php が配信するアイコンをレンダリングします (プレースホルダはプラグインルートに icon.svg を同梱します — エントリをブランディングするにはご自身のものに置き換えてください)。
プログラム経由 (テストまたは seeding)
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated
どちらのパスも Hook::fire('activate_plugin_acmecorp/loyalty') を発火します。スケルトンの Service Provider は boot() でそのイベント用の Hook::on(...) リスナーを登録しました — リスナーは Artisan::call('migrate', ['--path' => ..., '--force' => true]) を呼び、これが acmecorp_loyalty_settings テーブルを作成します。
ブラウザで /plugins/acmecorp/loyalty にアクセスすると、同梱の Hello World ページがレンダリングされます。@{{ trans('loyalty::messages.intro') }} ブロッククォートは storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php 配下の dump された翻訳ファイルから引きます。
最初の編集
スケルトンは意図的に最小限なので、すべてのサブシステムを一度に学ぶことなく、ピースを 1 つずつ置き換えられます。妥当な順序:
composer.json を更新する。 実際の title、description、version を設定。管理画面の Plugins ページがこれらを表示します。
- 実際の Migration を追加する。 既存のものより大きいタイムスタンプで
database/migrations/ 配下に新しいファイルを置きます。次のアクティベート (または deactivate-then-reactivate サイクル後) に実行されます。
- 実際のモデルを追加する。 スケルトンはプレースホルダとして
Setting を同梱します。src/Models/ 配下に独自のものを追加し、{Vendor_class}\{Name_class}\Models\YourModel として名前空間化してください。クラス名は小文字 vendor/name から自動派生します — acmecorp は Acmecorp、loyalty は Loyalty になります。
DashboardController を置き換える。 機能が実際に必要とするコントローラーを追加。薄く保ち — ビジネスロジックは src/Services/ クラスに押し込んでください。
- ビューを置き換える。 同梱の
index.blade.php は CDN から Bootstrap 5 を使用します。ほとんどのプラグイン作成者はこれを剥がし、代わりにホストアプリケーションのレイアウトを継承します。
ServiceProvider::boot() でフックを追加する。 4 つのパターンについては Hook system 深掘り を参照。スケルトンはすでに EVENT (Hook::on) と BEHAVIOR (Hook::set) を実演しています — 次に学ぶべきは REGISTRY と FILTER の 2 つです。
最初の日の 7 つのエラーと修正方法
新しいプラグイン作成者からのほぼすべての報告は、これら 7 つのカテゴリーのいずれかに該当します。それぞれが App\Model\Plugin または App\Providers\AppServiceProvider に出荷されるコードに裏付けられているので、症状は予測可能です。
1. 命名がバリデーターに違反
plugin:init が Plugin name must be in the "author/name" format または Author name "..." is invalid. Only lowercase letters and digits are allowed で例外を投げます。原因: 各サイド min:2 max:32 の regex ^[a-z0-9]+\/[a-z0-9]+$ がアンダースコア、ハイフン、大文字、2 文字未満のサイドを拒否します。
修正: 小文字と数字のみを使用 — 例えば acmecorp/loyalty、acme_corp/loyalty-points ではなく。
2. composer.json の name がフォルダと一致しない
scaffold 後、Plugin::register() はレンダリングされた composer.json の name が storage/app/plugins/ 配下のフォルダと一致することを検証します。JSON を別の vendor または name に編集してディレクトリを改名しないと、Plugin name in composer.json is expected to be '{folder}', found '{json}' を投げます。
修正: ディレクトリと JSON を同期して改名するか、新しい名前で plugin:init を再実行します。
3. autoload.psr-4 不足または不正
autoload ブロックが削除または誤記された場合、loadPluginByName() は Cannot boot plugin '{name}'. No 'autoload' found in composer.json (または対応する 'autoload.psr4' バリアント) を投げます。ランタイムはそのマップを使って名前空間を登録する必要があり、これがないと src/ 内のものは何もインスタンス化できません。
修正: scaffold された autoload.psr-4 エントリを保持してください。それが宣言する名前空間プレフィックス (Acmecorp\Loyalty\\) は、src/ 配下のすべての PHP ファイルの先頭の名前空間宣言と一致する必要があります。
4. 名前空間宣言が composer.json と一致しない
PHP の autoloader は、composer.json で宣言された Acmecorp\Loyalty\\ プレフィックスを剥がして、Acmecorp\Loyalty\Controllers\DashboardController を src/Controllers/DashboardController.php に解決します。ファイルが namespace AcmeCorp\Loyalty\Controllers を宣言する (AcmeCorp の C が大文字) と、autoloader は見つけられません。症状: 最初のリクエストで Class "Acmecorp\Loyalty\Controllers\DashboardController" not found。
修正: src/ 配下のすべての PHP ファイルの名前空間宣言は、小文字 vendor/name から派生した正確な大文字小文字を使う必要があります。acmecorp/loyalty なら、それは Acmecorp\Loyalty です。Plugin::makeClassNameFromString() は ucfirst のみを適用し — スマートな大文字小文字処理はありません。
5. 翻訳フックを register() ではなく boot() で登録
AppServiceProvider::boot() は自身の boot フェーズで Hook::collect('add_translation_file') を呼びます。プラグインの boot() が実行される頃にはそのループは終了しています — そこに翻訳エントリを追加すると、決して拾われず、trans('loyalty::messages.intro') はリテラルキーを返します。
修正: 翻訳はスケルトンと同じように register() で登録してください。activate_plugin_* と delete_plugin_* のライフサイクルフックは依然として boot() に属します。
6. boot() で $this->loadTranslationsFrom(...) を呼ぶ
よくある本能は、フックに加えて Laravel の loadTranslationsFrom() を直接呼ぶことです。プラグインの boot() は AppServiceProvider::boot の後に走るため、2 度目の呼び出しが、dump されたランタイムファイル (storage/app/data/plugins/...) を指していた名前空間ヒントを上書きし、マスターファイル (storage/app/plugins/.../resources/lang/...) に再ポイントします。目に見える症状は Languages UI での管理者編集がランタイムで反映されなくなることです — dump されたクローンがゾンビファイルになります。
修正: add_translation_file フックのみを使用してください。loadTranslationsFrom() も呼び出さないでください。
7. register() で他プラグインやカーネルに依存するフックを登録
register() はすべての他 Provider の register() 完了前、そしてあらゆる boot() よりずっと前に走ります。データベース、別プラグインのサービス、別 Provider の register() で配線されたシングルトンを必要とするコードは Class not found または Target class does not exist で失敗する可能性があります。register() に属する唯一のフックは add_translation_file です (AppServiceProvider::boot の collect ループの前に走る必要があるため)。
修正: その他のすべてのフックは boot() に置いてください。どうしても何かを早期に走らせる必要があるなら、まず app()->runningInConsole() または isInitiated() でゲートしてください。
ステップごとのチェックリスト
動作するプラグインを end-to-end で出荷する完全なシーケンス:
php artisan plugin:init {vendor}/{name} — scaffold。
composer.json を編集 — 実際の title、description、version を設定。
- Migration を
database/migrations/ 配下に書く。
- モデルを
src/Models/ 配下に追加。
- コントローラーを
src/Controllers/ 配下に追加。
- ビューを
resources/views/ 配下に追加。
- ルートを
routes.php で宣言。
ServiceProvider::boot() ですべてを配線 — ビュー、ルート、フック、アセット publish。
- 管理画面にサインイン → Plugins → Activate。Migration は自動的に実行されます。
何かが間違ったとき、2 つのデバッグエントリポイントがほぼすべてのケースをカバーします。storage/logs/laravel.log は autoload 登録中に loadPluginByName() 内で発生したものを含む、ブート中に投げられたあらゆる例外を捕捉します。storage/app/plugins/index.json の各行の error フィールドはそのプラグインの直近のブート失敗を表示し、管理画面の Plugins ページが赤いエラーピルを表示するために使うものです — プラグインを再アクティベート (または削除して再インストール) するとファイルがクリアされ、エラー状態がリセットされます。
次に読むべきページ
scaffold、ライフサイクル、そして最初の日のデバッグの大半をゲートする 7 つのエラーを得ました。次の 2 ページは残りのドキュメントが前提とするメンタルモデルを提供します:
- プラグインアーキテクチャ — ブート時のロードフロー、なぜ非アクティブなプラグインも autoload されるのか、マスターファイル機構、ランタイムレベルでの
register() と boot() の違い。
- Hook system — 4 つのパターン (REGISTRY、EVENT、BEHAVIOR、FILTER)、どれをいつ使うか、そして BEHAVIOR がサイレントなオーバーライドではなく衝突で例外を投げるコンフリクトセマンティクス。
本物の機能プラグインを出荷する準備ができたら、実例は 送信ドライバー (Postal MTA の end-to-end) と 決済ゲートウェイ (地域ゲートウェイとしての Paddle) です。UI 作業には、UI 注入 がプラグインがチャットボックスバブルや設定パネルを Blade を 1 つも fork せずにマウントできる layout/sidebar/page-slot フックをカバーします。