4 つの状態。4 つのモデルメソッド。1 つの JSON マスターファイル。

ホストアプリケーションのすべてのプラグインは 4 つの離散状態を遷移します: register (ディスク上のファイル + autoload + DB 行)、activate (マイグレーション + ステータスフリップ)、disable (ステータスフリップのみ — ルートとフックは生存)、delete (ロールバック + DB 行削除 + マスターファイルクリーンアップ)。各状態は app/Model/Plugin.php の単一メソッドで実装されており、各遷移は plugins データベーステーブルと storage/app/plugins/index.json の両方に書き込みを行います。本ページではすべての状態を順番に、ホスト側の具体ステップ込みで解説します。

4 つの状態の俯瞰

プラグイン行は 2 つの値 — active または inactive — のいずれかを持つ status カラムを備えます。さらに 2 つの状態が暗黙に存在します: 未登録 (DB 行なし、マスターファイルエントリなし) と 削除済み (ファイルディレクトリ消失、行消失、マスターファイルエントリ消失) です。これらの間を 4 つの遷移が動かします。

遷移メソッド遷移前ステータス遷移後ステータスディスク / DB で変化する内容
RegisterPlugin::register($name)(行なし)inactiveDB 行を挿入。マスターファイルエントリを書き込み。現リクエスト内でサービスプロバイダーがブート
Activate$plugin->activate()inactiveactiveactivate フック経由でマイグレーションを実行。DB ステータスをフリップ。マスターファイルの error をクリア
Disable$plugin->disable()activeinactiveDB ステータスをフリップ。マスターファイルの error をクリア。それ以外は何もしない。
Delete$plugin->deleteAndCleanup($keepData = false)任意(行なし)delete フックが発火 (典型的には migrate:rollback)。プラグインフォルダ削除。DB 行削除。マスターファイルエントリ削除

保持すべきメンタルモデル: register と delete は世界を変える (ディスク上のファイル、DB スキーマ)。activate と disable はフラグをフリップするだけ — register で導入されたルート、ビュー、フック、サービスプロバイダー状態はそのまま残ります。次の各セクションでは各遷移を順番に解説します。

状態 1 — Register / install

app/Model/Plugin.php:559Plugin::register($name) がエントリポイントです。php artisan plugin:init の末尾、および管理 Plugins ページからのアップロード成功時に自動的に呼び出されます。本メソッドは 5 つの明確な処理を順番に行います。

  1. composer.json を読み取るstorage/app/plugins/{vendor}/{name}/ から読み込み、titledescriptionversion をモデルにコピーします。composer の name フィールドがディレクトリと完全一致しない場合は throw します。
  2. 行を挿入 (または更新)plugins DB テーブルに status = inactive で挿入します。ルックアップは firstOrNew(['name' => $name]) なので、既存プラグインの再登録は重複ではなく更新になります。
  3. マスターファイルへの書き込み: storage/app/plugins/index.json{ "name": { "status": "inactive" } } エントリが追加されます。ホストがリクエスト毎に DB に問い合わせずに読むブート時レジストリです。
  4. サービスプロバイダーを即座にロード: $plugin->load($withServiceProvider = true) が、新しい Composer\Autoload\ClassLoader で PSR-4 プレフィックスを登録し、プラグインのサービスプロバイダークラスに対して App::register() を呼びます。メソッドが返るときには、プラグインのルート、ビュー、フックは稼働中プロセスに結線されています。
  5. 翻訳のマテリアライズとアセット publish: Language::dump()storage/app/data/plugins/{vendor}/{name}/lang/ 配下にロケール別ランタイムファイルを作成し、続いて artisan vendor:publish --force --tag=plugin が同梱アセットを public/plugins/{vendor}/{name}/ にコピーします。

register 完了後、プラグインは インストール済みかつロード済み です。まだ有効ではありません — つまり、プラグインが activate イベントに結線した処理がまだ走っていないだけです。プラグインのルート、ビュー、フックリスナーは すでに 稼働中です。

状態 2 — Activate

Plugin.php:484$plugin->activate() は管理画面「Activate」ボタンが呼び出すものです。順序付き 4 ステップ。

  1. activate フックを発火: Hook::fire('activate_plugin_'.$this->name)。この名前に登録されたすべてのリスナーが走ります — 典型的にはプラグイン自身の Hook::on('activate_plugin_*', ...) リスナーが、プラグインの migrations フォルダに対して artisan migrate を呼びます。他のプラグインも同じイベントに追加リスナーを登録できます。
  2. composer.json を再検証: self::validateMetaData($config) がプラグインの必須キー (nameversionapp_version) が存在し正しい形式であることを検証します。キー欠落時はステータスフリップ前に throw します。
  3. DB ステータスを active に設定 して行を保存します。
  4. マスターファイルを更新: { "status": "active", "error": null }error リセットにより以前のブート失敗がクリアされ、以降の autoload スイープがこのプラグインを健全とみなすようになります。

有効化は実用上べき等です。すでにアクティブなプラグインで activate() を再実行するとフックを再発火し (migrate を実行するリスナーが再度走りますが、Laravel のマイグレーションテーブルが既実行ファイルを重複排除するため 2 回目は no-op)、再検証し、同じステータスを書き込みます。「すでにアクティブ」の特別分岐はありません。

状態 3 — Disable

Plugin.php:136$plugin->disable() は 4 メソッドの中で最もシンプルです。次のことだけを行います。

  1. DB ステータスを inactive に設定。
  2. マスターファイルを新ステータスで更新し、error フィールドをクリア。

メソッドの内容はこれで全部です。何もアンロードしません。

プラグインの boot() 中に登録されたルートは登録されたまま残ります。ビューは引き続きマウント可能です。フックリスナーはホストがフックを発火する限り走り続けます。プラグインのサービスプロバイダーはアプリケーションコンテナにロードされたままで、次のリクエストでも autoloadWithoutDbQuery() がステータスに関係なくマスターファイルの全エントリを読むため、再度ロードされます。Disable はステータスフリップであり、アンロードではありません — Laravel 自体がサービスプロバイダーのブート後の登録解除をサポートしていません。

これが acelle/console プラグインが「無効化時にプラグイン機能を消す」標準パターンとなっている理由です。ルートは常にロードされますが、console.active という名前のルート Middleware が、Plugin::getByName('acelle/console')->isActive() が false を返すときに 404 で abort します。チェックはリクエスト毎に 現在の DB ステータスに対して行われるため、プラグインを無効化すると次のリクエストからルートが 404 を返します。

「見える無効化」パターンの 3 ステップ。 (1) Plugin::enabled('myvendor/myplugin') をチェックし、false なら 404 で abort するルート Middleware を定義する。(2) サービスプロバイダーの boot() で Middleware エイリアスとして登録する。(3) routes.php でルートグループに適用する。ユーザーが見える機能を提供するすべてのプラグインはこのパターンに従うべきです — これがないと、ユーザーから見て「無効化済み」と「有効化済み」が見分けられません。

状態 4 — Delete

Plugin.php:670$plugin->deleteAndCleanup($keepData = false) が完全な解体処理です。順序付き 4 ステップ。

  1. delete フックを発火: Hook::fire('delete_plugin_'.$name, [$keepData])。スケルトンのリスナーは、プラグインの migrations フォルダに対して artisan migrate:rollback を呼びます。$keepData フラグは転送され、顧客データを保持するテーブルのロールバックをリスナーがオプトアウトできるようにします — 実例パターンはデータベースとモデルのページを参照。
  2. プラグインディレクトリを削除: $this->deletePluginDirectory()storage/app/plugins/{vendor}/{name}/ を再帰的に削除します。このステップ後、プラグインの PHP ソースはディスクから消えます。
  3. DB 行を削除。 plugins テーブルは以降このプラグインを参照しません。
  4. マスターファイルエントリを削除: updatePluginMasterFile($name, null)null は新フィールドのマージではなくエントリ削除を意味する慣例です。

次のリクエストが新プロセスをブートするまで、プラグインのルート、ビュー、フックはメモリ上にロードされたままです — プロセス内 Laravel コンテナには「このプラグインのサービスプロバイダーを登録解除する」という概念がありません。次のリクエストは (縮小された) マスターファイルを読み、プラグインをロードせず、メモリ内状態は前リクエストのライフサイクルとともに破棄されます。

各遷移時のマスターファイル

storage/app/plugins/index.json がブート時の単一の真実の源です。上記すべての遷移はここに書き込みます。ライフサイクルを把握するには、1 つのプラグインのエントリが各ステップで何になるかを見るのが有用です。

// Before register: no entry.
{}

// After register:
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After activate:
{
  "acmecorp/loyalty": { "status": "active" }
}

// After a boot failure (sticky until cleared by activate):
{
  "acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}

// After disable (error cleared, status flipped):
{
  "acmecorp/loyalty": { "status": "inactive" }
}

// After delete: no entry.
{}

ホスト側の 3 つのメソッドがファイルを所有します。マージ書き込み用の updatePluginMasterFile($name, $params) (第 2 引数に null を渡すとエントリを削除)、DB と同期が崩れたときにファイルを Plugin::all() から再構築する resetPluginMasterFile()、そして全エントリを読み非空の error を持つ名前を返す getErroredPluginNames() です。

壊れた状態からの復旧

本番では 3 つの失敗モードが現れます。

1. マスターファイルのプラグイン行が古い、または誤っている

手動編集、部分デプロイ、データベーススナップショットのリストア後に頻発します。修正: php artisan tinker を起動して Plugin::resetPluginMasterFile() を呼びます。このメソッドは DB から Plugin::all() をイテレートし、JSON ファイルをゼロから書き直し、ステータスを保持しつつ全 error フィールドをクリアします。

2. プラグインの error フィールドが設定され、管理 Plugins ページに赤いピルが表示される

エラーは sticky です — autoloadWithoutDbQuery()loadPluginByName() 呼び出しを try/catch でラップし、呼び出しが throw したときにセットされます。エラーは activate() 成功 (error => null をセット) か disable() (同様) があるまで残ります。修正: 根本問題 (autoload.psr-4 欠落、名前空間不一致、サービスプロバイダークラス不在) を解決し、Activate をクリック。次のブートが成功し、エラーがクリアされます。

3. プラグインフォルダが消失しているがマスターファイルエントリが残っている

手動の rm -rf 後に発生します。ブートはマスターファイルエントリ経由でプラグインをロードしようとし、throw し、エラーを記録します。修正: Plugin::updatePluginMasterFile($name, null) で直接マスターファイルエントリを削除するか — プラグインがまだ存在すべきなら — ソースアーカイブを再アップロードして Plugin::register($name) を再実行し、すべてを再投入します。

plugin:* コンソールコマンド

ホストには 1 つの artisan コマンドが同梱されています: plugin:initplugin:activateplugin:disableplugin:delete コマンドはありません — これらは管理 UI のアクションです。プログラム的なアクセスはモデルメソッド経由で直接行います。

php artisan tinker

>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();   // → active, runs migration via activate hook
>>> $p->disable();    // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true);  // → preserve customer-facing tables

これは管理 Plugins ページが内部で使用するのと同じ表面です。CI スクリプト、シーダー、統合テストはすべてこれらのメソッドを直接呼びます。テストディープダイブ がテストスイートレベルのパターンを扱います。

1 つの図で見る状態遷移

          ┌─────────────────────┐
          │   not registered    │  (no row, no master-file entry)
          └──────────┬──────────┘
                     │ Plugin::register($name)
                     │  ├─ writes DB row (status=inactive)
                     │  ├─ writes master file
                     │  ├─ loads service provider in-process
                     │  └─ Language::dump() + vendor:publish
                     ▼
          ┌─────────────────────┐
   ┌────▶ │      inactive       │ ◀───┐
   │      └──────────┬──────────┘     │
   │                 │                │
   │       activate()│                │ disable()
   │                 │                │   ├─ status=inactive
   │                 │                │   └─ master file updated
   │                 ▼                │
   │      ┌─────────────────────┐     │
   │      │       active        │ ────┘
   │      └──────────┬──────────┘
   │                 │
   │     deleteAndCleanup($keepData)
   │                 │  ├─ fires delete hook (rollback unless $keepData)
   │                 │  ├─ removes plugin folder
   │                 │  ├─ deletes DB row
   │                 │  └─ removes master-file entry
   │                 ▼
   │      ┌─────────────────────┐
   └──────│   not registered    │
          └─────────────────────┘
          (cycle: register again to re-install)

5 つのアンチパターン

1. disable がプラグインをアンロードするかのように扱う

ルートは依然として登録され、フックは依然として発火し、ビューは依然としてマウントされます。修正: acelle/console と同様、Plugin::enabled(...) Middleware またはインラインチェックでユーザーに見える機能をガードします。

2. 本番でマスターファイルを手動編集する

JSON を簡単に壊せます。修正: tinker 経由で Plugin::updatePluginMasterFile() または Plugin::resetPluginMasterFile() を呼びます — どちらも検証を行います。

3. マスターエントリを削除せずに rm -rf storage/app/plugins/{vendor}/{name} する

ブートが存在しないプラグインをロードしようとし続け、エラーを記録します。修正: フォルダ削除と Plugin::updatePluginMasterFile($name, null) を必ずセットで行うか、両方を実行する deleteAndCleanup() を使います。

4. サービスプロバイダーの boot() 内から activate() を呼ぶ

ブートフェーズはプロセスごとに 1 度走ります。そこで activate() を呼ぶとリクエスト毎に activate フックが発火します。マイグレーションは毎回実行され (べき等ですが高コスト)、副作用リスナーも発火します。修正: 有効化は管理 UI のアクションです。ブート時の副作用にしてはいけません。

5. registeractivate より先に起きることを忘れる

一部のプラグインは activate フックリスナーでデフォルトデータをシードしようとし、プラグイン自身のマイグレーションに依存する Eloquent モデルを参照しますが、初回 activate ではマイグレーションがまだ走っていません。修正: マイグレーションリスナーは activate の途中で、新テーブルを参照しうる他の Hook::on('activate_plugin_*') リスナーよりも先に走ります。登録の順序を整え、マイグレーションを先頭に置いてください (スケルトンはそうなっています — その状態を維持しましょう)。

次のステップ

ライフサイクルは いつ を扱い、テスト検証 を扱います。次ページでは phpunit.xml testsuite 登録、PluginTestCase ベースクラスパターン、hooks-under-test アサーション、activate-test-delete CI サイクルを順に解説します。