> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# パッケージサービスのライフサイクル設計

> Laravel 13のbind・singleton・scopedの内部実装を読み解き、長寿命ワーカーで状態を持ち越さないパッケージ設計とライフサイクル境界のテストを解説します。

パッケージを通常のHTTPリクエストだけでなく、Octaneやキューワーカーでも使えるようにするには、サービスが**どの期間、何を保持してよいか**を決める必要があります。リクエストごとのデータをsingletonに保存すると、別の処理でも古いデータを参照する可能性があります。

このページではLaravel Framework `v13.36.0` のコンテナとキューワーカー、およびOctaneの実装を確認し、[サービスコンテナ](/jp/service-container)の基本的な登録方法をパッケージの長期保守に結び付けます。`scoped()` を選ぶだけで、オブジェクト間の参照や静的状態がすべてリセットされるわけではありません。

## 登録方法はサービスの保持期間で選ぶ

| 登録 | 通常の `make()` による取得 | 向いている設計 |
| - | - | - |
| `bind()` | 解決のたびに生成する | 操作単位で生成してよいサービス |
| `singleton()` | 同じコンテナでは生成済みインスタンスを再利用する | リクエスト・ジョブ固有の状態を保持しないサービス |
| `scoped()` | 同じライフサイクルでは再利用し、境界で保存済みインスタンスを除去する | 1リクエスト・1ジョブ内で共有する状態 |

これは通常の解決を比較した表です。パラメーターを明示した解決やcontextual bindingなどの特殊な経路は別に確認してください。

`bind()` でも、取得したサービスを別のsingletonのプロパティに保存すれば、その参照は残ります。登録方法はコンテナの生成・再利用規則であって、利用者が保持するオブジェクトまで強制的に作り直す規則ではありません。

## scopedは共有登録と除去対象の記録を組み合わせる

確認した `Container::scoped()` は、次の2つを行います。

1. 登録キーを `scopedInstances` に記録する。
2. `singleton()` を呼び、共有バインディングとして登録する。

インスタンス自体はsingletonと同じ `instances` に保存されます。`forgetScopedInstances()` は、記録されたキーについて保存済みインスタンスを `unset()` します。バインディングの生成処理は残るため、次の通常の解決で新しいオブジェクトを作れます。

```mermaid theme={null}
flowchart TD
    A["register()でscopedを登録"] --> B["生成方法と除去対象のキーを記録"]
    B --> C["処理Aで初めてmake()"]
    C --> D["インスタンスAを生成・保存"]
    D --> E["処理A内では同じインスタンスを取得"]
    E --> F["ライフサイクル境界で<br>forgetScopedInstances()"]
    F --> G["コンテナの保存済みインスタンスを除去"]
    G --> H["処理Bのmake()でインスタンスBを生成"]
```

この処理は、古いオブジェクトのプロパティを初期化したり、別オブジェクトが持つ参照を書き換えたりしません。参照が残っている古いオブジェクトは、引き続き利用できてしまいます。

### 境界を作るのは実行環境

コンテナ自身がHTTPリクエストやジョブの開始を監視しているわけではありません。

* **キューワーカー**: `QueueServiceProvider` がスコープをリセットするコールバックをWorkerに渡します。`Worker::daemon()` は次のジョブを取得する前のループでこれを呼び、コールバック内で `forgetScopedInstances()` とFacadeの解決済みインスタンスのクリアなどを行います。
* **Octane**: 公式の既定設定では `OperationTerminated` に `FlushTemporaryContainerInstances` を登録します。このリスナーが `forgetScopedInstances()` を呼びます。
* **独自の常駐コマンドやループ**: 単に `make()` を繰り返しても境界にはなりません。実行基盤側でライフサイクルを設計する必要があります。

<Warning>
  通常のアプリケーション処理やパッケージの `boot()` から、独自判断で `forgetScopedInstances()` を呼ばないでください。コンテナ全体のscopedサービスに影響します。また、同期実行を含むすべてのジョブ経路が、常駐Workerと同じリセット経路を通るとは限りません。
</Warning>

## 処理内で共有する状態をscopedにする

配送パッケージが、1つのリクエスト・ジョブ内で配送先の識別子を共有したい例を考えます。HTTPのRequestを直接保持せず、必要な値だけを保存します。

```php theme={null}
<?php

namespace Acme\Courier;

final class DeliveryContext
{
    private ?string $destinationId = null;

    public function setDestinationId(string $destinationId): void
    {
        $this->destinationId = $destinationId;
    }

    public function destinationId(): ?string
    {
        return $this->destinationId;
    }
}
```

パッケージのサービスプロバイダーでは、生成時に利用者の状態を読み取らず、空のcontextを登録します。

```php theme={null}
<?php

namespace Acme\Courier;

use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->scoped(DeliveryContext::class);
    }
}
```

利用アプリケーションのコントローラーやジョブの `handle()` で、入力の検証や権限の確認を済ませた識別子を設定します。同じライフサイクルでこの型を解決する他のサービスは、同じcontextを参照できます。

```php theme={null}
$context = app(\Acme\Courier\DeliveryContext::class);
$context->setDestinationId($authorizedDestinationId);
```

このcontextは認可機能ではありません。識別子を設定できることと、その配送先へのアクセスが許可されていることは別です。また、`scoped()` はデータベースや外部キャッシュへの永続化でもありません。次のジョブに渡す値はジョブのデータとして明示的に渡します。

<Info>
  Octaneではサービスプロバイダーの `register()` と `boot()` はワーカーの初期起動時に実行されます。現在のユーザーや配送先を `boot()` でcontextに保存する設計にしないでください。処理ごとの値は、処理が始まってから設定します。
</Info>

## singletonからscopedへの参照は自動更新されない

次の登録は、contextだけをscopedにしても意図通りにはなりません。

```php theme={null}
$this->app->singleton(DeliveryService::class, function ($app) {
    return new DeliveryService($app->make(DeliveryContext::class));
});
```

`DeliveryService` がコンストラクターでcontextをプロパティに保存するなら、最初に生成したcontextを持ち続けます。コンテナからcontextの保存を除去しても、すでに生成済みの `DeliveryService` のプロパティは変わりません。

対策は、サービスの責務に合わせて選びます。

1. **処理ごとに必要な値をメソッド引数で渡す**。状態を共有する必要がなければ、これが単純です。
2. **保持する側もscopedにする**。`DeliveryService` とcontextの両方を処理単位で作り直します。
3. **利用時に解決するresolverを渡す**。singletonを維持する必要がある場合は、現在のコンテナから必要なサービスをその都度取得します。

3番目の例では、古いcontextをクロージャーに取り込むのではなく、呼び出す時点で解決します。

```php theme={null}
use Illuminate\Container\Container;

$this->app->singleton(DeliveryService::class, function () {
    return new DeliveryService(
        fn () => Container::getInstance()->make(DeliveryContext::class),
    );
});
```

この例の `DeliveryService` は、コンストラクターで `Closure` を受け取り、**操作のたびに呼び出すAPI**へ変更する必要があります。コンストラクター内でresolverを一度だけ呼び、その結果を保存しては解決になりません。これは公式Octaneドキュメントのcontainer resolverを、パッケージ固有のcontextに応用した設計例です。

### scopedで消えない状態も確認する

`scoped()` は、次の状態を自動的には除去しません。

* パッケージ独自の静的プロパティに追加した値
* 別のsingletonやグローバル変数に保存したcontextへの参照
* singletonの内部に保持した「現在のユーザー」「現在の配送先」
* 独自のFacade・キャッシュ層が保持する参照

標準の実行基盤によるリセットと、パッケージ独自の状態のリセットは区別します。処理ごとの状態を保持する場所を減らす方が、すべての実行環境で独自のクリア処理を維持するより単純です。

## 同じコンテナで境界をまたぐテストを書く

HTTPテストが毎回新しいアプリケーションを作るだけでは、常駐プロセスで同じコンテナを使い続ける問題を見逃すことがあります。まずはコンテナ単体で、同じ処理内の共有と境界後の再生成を検証します。

次のテストはPHPUnit形式です。最初のテストは公式のContainerテストと同様に `forgetScopedInstances()` を使い、Worker全体を起動せず境界の作用を確認します。

```php theme={null}
<?php

namespace Acme\Courier\Tests;

use Acme\Courier\DeliveryContext;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

class ServiceLifetimeTest extends TestCase
{
    public function test_context_is_shared_then_recreated(): void
    {
        $container = new Container;
        $container->scoped(DeliveryContext::class);

        $first = $container->make(DeliveryContext::class);
        $first->setDestinationId('destination-a');

        $this->assertSame($first, $container->make(DeliveryContext::class));
        $this->assertSame('destination-a', $first->destinationId());

        $container->forgetScopedInstances();

        $second = $container->make(DeliveryContext::class);
        $this->assertNotSame($first, $second);
        $this->assertNull($second->destinationId());
    }

    public function test_singleton_keeps_a_captured_scoped_dependency(): void
    {
        $container = new Container;
        $container->scoped(DeliveryContext::class);
        $container->singleton('courier.holder', function ($app) {
            return (object) ['context' => $app->make(DeliveryContext::class)];
        });

        $holder = $container->make('courier.holder');
        $oldContext = $holder->context;

        $container->forgetScopedInstances();

        $this->assertSame($holder, $container->make('courier.holder'));
        $this->assertSame($oldContext, $holder->context);
        $this->assertNotSame($oldContext, $container->make(DeliveryContext::class));
    }
}
```

2番目は、参照が残る仕様を記録するテストです。実際のパッケージには、採用した対策で新しいcontextや値が使われることを検証するテストも追加します。

| 更新時の確認 | 期待する結果 |
| - | - |
| 同じ処理内でcontextを複数回解決 | 同じインスタンス・設定した値を共有する |
| 同じコンテナで境界をまたいで解決 | 新しいインスタンスで、前の値を持たない |
| singletonがcontextを利用 | 古いcontextを保存せず、現在の値を使う |
| 利用アプリでOctaneの連続リクエストを実行 | 2回目が1回目の配送先などを引き継がない |
| 常駐キューワーカーで連続ジョブを実行 | ジョブごとの設定値を分離する |
| Laravel・Octaneを更新 | リセットの呼び出し経路と利用アプリの設定を再確認する |

コンテナ単体テストは、Octaneのsandboxやキューワーカーの実際の実行経路を検証するものではありません。対応を表明する実行環境で連続処理を行う結合テストも必要です。登録方法やサービス実装を変更したデプロイでは、常駐プロセスを再読み込みして新しいコードを適用します。

## 関連ページ

<Columns cols={2}>
  <Card title="パッケージのサービス解決後フック" icon="puzzle-piece" href="/jp/advanced/package-service-resolution">
    afterResolvingと共有インスタンスの取得、再解決時の設定の違いを確認します。
  </Card>

  <Card title="Laravel Octane" icon="bolt" href="/jp/octane">
    Requestやコンテナの保持、常駐プロセスの運用上の注意点を確認します。
  </Card>
</Columns>

## 参照した一次情報

* [Laravel公式ドキュメント: Binding scoped singletons](https://github.com/laravel/docs/blob/13.x/container.md#binding-scoped-singletons)
* [Laravel公式ドキュメント: Dependency injection and Octane](https://github.com/laravel/docs/blob/13.x/octane.md#dependency-injection-and-octane)
* [Container: scoped()・singleton()・forgetScopedInstances()・resolve()](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Container/Container.php)
* [ContainerTest: scopedの共有と再生成](https://github.com/laravel/framework/blob/v13.36.0/tests/Container/ContainerTest.php)
* [QueueServiceProvider: Workerに渡すresetScope](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Queue/QueueServiceProvider.php)
* [Worker: daemon()でのスコープリセット](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Queue/Worker.php)
* [Octane: FlushTemporaryContainerInstances](https://github.com/laravel/octane/blob/ecc9254e6d79cd607f191015f634f9be944e2267/src/Listeners/FlushTemporaryContainerInstances.php)
* [Octane: OperationTerminatedの既定リスナー](https://github.com/laravel/octane/blob/ecc9254e6d79cd607f191015f634f9be944e2267/config/octane.php)


## Related topics

- [Laravelパッケージ開発](/jp/advanced/package-development.md)
- [応用トピック](/jp/advanced/index.md)
- [リクエストライフサイクル](/jp/lifecycle.md)
- [セッションライフサイクルイベント](/jp/packages/laravel-copilot-sdk/session-lifecycle-event.md)
- [サービスコンテナ](/jp/service-container.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.