> ## 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のジョブ保存・復元を読み解き、クラス名とプロパティの互換性、モデルの再取得、旧ペイロードのテスト、ワーカー更新の順序を解説します。

パッケージを更新しても、更新前に投入したジョブを処理できるようにします。キューに保存されるのは実行するコードではなく、ジョブクラスの名前と状態です。新しいコードの単体テストだけでなく、**古いコードが保存した状態を新しいコードで復元するテスト**が必要です。

> [!NOTE]
>
> このページはLaravel 13を対象に、Framework `v13.42.0` の実装を確認しています。[キューの基本](https://kawax.biz/jp/queues.md)と[パッケージのテスト](https://kawax.biz/jp/advanced/package-testing.md)を前提とします。以下の互換性設計はパッケージ側の方針であり、Laravelが独自のペイロード形式を自動移行する機能ではありません。

## 保存と実行の間にコードが変わる

オブジェクトのジョブでは、`Queue::createObjectPayload()` が `serialize(clone $job)` を実行し、結果をペイロードの `data.command` に入れます。暗号化する場合も、その前段には同じシリアライズがあります。

ワーカー側の `CallQueuedHandler::getCommand()` は保存済みデータを `unserialize()` し、復元したジョブをDispatcherへ渡します。通常の `handle()` は、その時点で動いているワーカーのコードとサービスコンテナで実行されます。

```mermaid theme={null}
sequenceDiagram
    participant P as 投入側<br>パッケージ旧版
    participant Q as キュー
    participant W as ワーカー<br>パッケージ新版

    P->>Q: クラス名とプロパティを保存
    Note over P,W: デプロイでコードを更新
    Q->>W: 保存済みペイロードを取得
    W->>W: 現在のクラスへ復元
    W->>W: 現在のhandle()を実行
```

長寿命ワーカーを再起動していなければ、更新後も旧コードが動き続けます。ローリングデプロイでは「旧投入側と新ワーカー」だけでなく、「新投入側と旧ワーカー」の組み合わせも考えます。

| 変更するもの | 保存済みジョブへの影響 |
| - | - |
| ジョブの完全修飾クラス名 | 旧名でクラスを読み込めないと、正常なジョブへ復元できない |
| プロパティ名・可視性 | 保存時のキーと復元時のキーが一致しなくなる可能性がある |
| プロパティの型 | 旧値を代入できず、復元時に失敗する可能性がある |
| プロパティの宣言時既定値 | 保存を省略された値の意味が変わる可能性がある |
| `handle()` や依存サービス | 復元できても、旧データに対する処理結果が変わる可能性がある |

クラスの改名や削除は、利用者が直接そのクラスを呼んでいるかだけで判断しません。待機中・遅延中・再試行待ちのジョブや、失敗ジョブの保存期間も互換性の対象です。

## SerializesModelsが保存するもの

`Illuminate\Foundation\Queue\Queueable` は、内部で `SerializesModels` を利用しています。これはモデルの置換だけでなく、ジョブ自身のプロパティを列挙する `__serialize()` と、値を戻す `__unserialize()` も提供します。

### 宣言時の既定値と等しい値は保存されない

`__serialize()` は、static・未初期化などのプロパティを除外し、**プロパティの宣言時既定値と現在値が厳密に等しい場合も保存を省略**します。

```php theme={null}
public string $format = 'csv';
```

このプロパティが `'csv'` のままなら、保存配列に `format` は入りません。後のリリースで宣言時既定値を `'json'` に変えると、旧ジョブにも `'json'` が適用され得ます。単なる見た目の既定値変更ではなく、旧データの読み方の変更です。

### 復元時にコンストラクタは呼び直されない

`__unserialize()` は、現在のクラスに存在し、保存配列にもキーがあるプロパティへ値を代入します。コンストラクタを通じた入力の補完や検証は実行しません。

```php theme={null}
public function __construct(
    public string $format = 'csv',
) {}
```

この例の `'csv'` は**コンストラクタ引数の既定値**です。プロパティ自体の宣言時既定値ではないため、`format` を持たない旧ペイロードを復元したときの補完にはなりません。追加した型付きプロパティが未初期化のままになれば、後でアクセスしたときに失敗します。

また、publicのキーは名前そのもの、protectedは可視性を表す接頭辞付き、privateはクラス名も含むキーになります。プロパティをpublicからprivateへ変える変更も、旧ペイロードで検証します。

> [!WARNING]
>
> `SerializesModels` が列挙するのはジョブのプロパティです。任意のDTOの内部や、配列の奥にあるモデルまで再帰的に同じ変換が行われるとは考えません。モデル以外のオブジェクトを渡す場合は、そのクラスと保存形式も互換性の対象になります。

## 識別子と実行サービスを分ける

実行に必要な識別子や選択肢をジョブへ保存し、APIクライアントやExporterなどの実行サービスは `handle()` に注入します。コンストラクタにサービスを渡して保持すると、その内部状態まで保存対象になり得ます。

次は、旧版では `reportId` だけを持っていた同名ジョブへ、形式の選択とペイロードバージョンを追加する例です。`ReportExporter` はパッケージが提供してコンテナにbindする契約で、`export(string $reportId, string $format): void` を持つものとします。

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

namespace Acme\Reports\Jobs;

use Acme\Reports\Contracts\ReportExporter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use UnexpectedValueException;

class GenerateReport implements ShouldQueue
{
    use Queueable;

    public string $format = 'csv';

    public int $payloadVersion = 1;

    public function __construct(
        public string $reportId,
        string $format = 'csv',
    ) {
        $this->format = $format;
        $this->payloadVersion = 2;
    }

    public function handle(ReportExporter $exporter): void
    {
        if (! in_array($this->payloadVersion, [1, 2], true)) {
            throw new UnexpectedValueException('Unsupported report payload version.');
        }

        $exporter->export($this->reportId, $this->format);
    }
}
```

この設計では、欠けた値の意味と、新しく投入する値を分けています。

* 旧ペイロードに `format` がなければ、宣言時既定値の `'csv'` を利用する。
* 旧ペイロードに `payloadVersion` がなければ、宣言時既定値の `1` として扱う。
* 新規生成時はコンストラクタが `payloadVersion` を `2` にする。宣言時既定値と異なるので、この値は保存される。
* 新規ジョブの `format` が `'json'` なら、その値も保存される。`'csv'` なら省略されるため、宣言時既定値の `'csv'` を今後も維持する。

将来、新規ジョブの既定形式だけを変える場合は、コンストラクタ引数の既定値と代入を変更し、旧データ用のプロパティ既定値とは分けて検討できます。ペイロードバージョンはパッケージのリリース番号ではなく、**保存形式と解釈の世代**として管理します。

> [!TIP]
>
> バージョン番号を追加するだけでは互換性は得られません。この例の新版は旧形式も処理できますが、旧版のワーカーが新しい `format` や `payloadVersion` を理解する保証はありません。後述の投入側とワーカーの更新順序も必要です。

## モデルを渡す場合はスナップショットと区別する

モデルを直接プロパティに保存すると、`SerializesAndRestoresModelIdentifiers` はクラス・ID・接続名・読み込み済みリレーション名などを `ModelIdentifier` に置き換えます。単一モデルの復元では、DBから `firstOrFail()` で再取得し、対象のリレーションを読み込みます。

これは、投入時点の属性や関連データを固定する仕組みではありません。

* 実行前にレコードが更新されれば、復元後は更新された内容を参照する。
* 事前に適用したリレーションの絞り込み条件は保存されないため、必要な条件は実行時に付け直す。
* 不要な関連は `withoutRelations()` や `#[WithoutRelations]` で保存対象から外す。
* モデルが削除された場合、ジョブの `handle()` より前の復元段階で失敗し得る。

削除済みモデルのジョブを破棄してよい場合は、公式の `#[DeleteWhenMissingModels]` を検討できます。ただし、パッケージが勝手に破棄を既定動作にせず、利用者へ失敗方針を公開します。IDだけを渡して `handle()` 内で検索する設計なら、その検索の失敗やスキップは自身で制御します。

投入時点の内容が必要なら、必要な値を明示して保存するか、不変のレポート要求などをDBへ記録し、そのIDを渡します。単にモデルを渡すことと、業務データのスナップショットを保存することを区別します。

### コミット後の投入は別の問題

トランザクション中に作成したレコードを処理する場合は、コミット前にワーカーが読み始めないようにします。

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($reportId) {
    // このトランザクション内で、対象レコードを作成・更新する。

    GenerateReport::dispatch($reportId)->afterCommit();
});
```

`afterCommit()` は、開いている親トランザクションのコミット後まで投入を待ち、ロールバック時はその投入を破棄します。クラス名・プロパティの互換性や、外部処理の重複実行を解決する機能ではありません。

## fakeと旧ペイロードのテストを分ける

通常の `Queue::fake()` は投入を記録するためのものです。`assertPushed()` が通っただけでは、保存・復元や、新版による旧ペイロードの処理を確認したことにはなりません。

現在のクラスでの往復は、次のように検証できます。

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\Queue;

Queue::fake()->serializeAndRestore();

GenerateReport::dispatch('report-001', 'json');

Queue::assertPushed(GenerateReport::class, function (GenerateReport $job) {
    return $job->reportId === 'report-001'
        && $job->format === 'json'
        && $job->payloadVersion === 2;
});
```

`serializeAndRestore()` はfake内で `serialize()` と `unserialize()` を実行します。ただし、実バックエンドへの投入・ワーカーによる実行・旧版コードでの生成は検証しません。

### 旧版で生成したfixtureを残す

旧版の `GenerateReport` が `reportId` だけを持つ状態で、実データを含まない固定のジョブを生成します。以下は旧版のテスト環境で一度実行する準備用コードです。`tests/fixtures/` は事前に用意します。

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;

file_put_contents(
    __DIR__.'/fixtures/generate-report-v1.serialized',
    serialize(new GenerateReport('report-legacy')),
);
```

fixtureをバージョン管理し、新版のテストから読み込みます。テストのたびに新版で再生成すると、後方互換性の検証ではなくなるため、固定したままにします。

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

namespace Acme\Reports\Tests;

use Acme\Reports\Jobs\GenerateReport;

class JobCompatibilityTest extends TestCase
{
    public function test_new_payload_keeps_explicit_values(): void
    {
        $job = unserialize(serialize(new GenerateReport('report-001', 'json')));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-001', $job->reportId);
        $this->assertSame('json', $job->format);
        $this->assertSame(2, $job->payloadVersion);
    }

    public function test_default_format_is_omitted_but_new_version_is_saved(): void
    {
        $values = (new GenerateReport('report-001'))->__serialize();

        $this->assertArrayNotHasKey('format', $values);
        $this->assertSame(2, $values['payloadVersion']);
    }

    public function test_old_payload_uses_legacy_defaults(): void
    {
        $job = unserialize(file_get_contents(
            __DIR__.'/fixtures/generate-report-v1.serialized',
        ));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-legacy', $job->reportId);
        $this->assertSame('csv', $job->format);
        $this->assertSame(1, $job->payloadVersion);
    }
}
```

この例の `TestCase` は[パッケージのテスト環境](https://kawax.biz/jp/advanced/package-testing.md)で用意する基底クラスです。`unserialize()` へ渡すのは自身で管理するfixtureだけにし、外部入力を渡さないでください。

さらに、復元した旧ジョブの `handle()` をテスト用Exporterで実行し、出力や保存結果まで検証します。モデルを渡す場合は更新・削除・接続・関連の再取得を、実際に利用するキュー接続ではコミット後の投入や再試行も別途検証します。

## 新形式を投入する前に読み取り側を更新する

**1. 旧形式を読めるリリースを用意する**

旧クラス名と旧値の解釈を維持し、新形式も読めるコードを先に配布します。fixtureで復元だけでなく処理結果も確認します。

**2. 旧ワーカーが残らないことを確認する**

利用アプリのデプロイ手順にワーカーの入れ替えを含めます。通常の `queue:work` では `php artisan queue:restart` で現在のジョブ処理後の終了を要求し、プロセスマネージャーが新版を起動することを確認します。再起動シグナルにはキャッシュが使われます。

**3. 新形式の投入を有効にする**

読み取り側が対応してから、機能フラグなどで新形式の投入へ切り替えます。同じコードの配布だけで新形式の生成も始まる場合は、投入を一時停止するか、新旧のキューと対応ワーカーを分けるなど、旧ワーカーへ渡さない手順が必要です。

**4. 再試行とロールバックの期間も保守する**

失敗ジョブの再試行や、チェーン内で後から投入されるジョブも確認します。新形式を保存した後に旧コードへ戻すなら、旧コードでも読めるかを検証します。キューが一度空になっただけで旧クラスや旧形式の対応を削除しません。

`queue:restart` は保存済みペイロードを書き換えるコマンドではありません。パッケージの更新案内には、対応形式、必要なワーカーの入れ替え、旧形式を廃止する条件を明記します。[バージョン互換性管理](https://kawax.biz/jp/advanced/package-versioning.md)と[Changelogの運用](https://kawax.biz/jp/advanced/package-changelog.md)にも同じ変更を記録します。

## 参照した一次情報

* [Laravel公式ドキュメント: ジョブの構造と依存注入](https://github.com/laravel/docs/blob/13.x/queues.md#class-structure)
* [Laravel公式ドキュメント: コミット後の投入](https://github.com/laravel/docs/blob/13.x/queues.md#jobs-and-database-transactions)
* [Laravel公式ドキュメント: ワーカーとデプロイ](https://github.com/laravel/docs/blob/13.x/queues.md#queue-workers-and-deployment)
* [Laravel公式ドキュメント: 削除されたモデルの扱い](https://github.com/laravel/docs/blob/13.x/queues.md#ignoring-missing-models)
* [Queue: オブジェクトのペイロード作成](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/Queue.php)
* [Queueable: ジョブ用トレイトの構成](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Foundation/Queue/Queueable.php)
* [SerializesModels: プロパティの保存と復元](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesModels.php)
* [SerializesAndRestoresModelIdentifiers: モデルの置換と再取得](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php)
* [CallQueuedHandler: 復元と実行](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/CallQueuedHandler.php)
* [QueueFake: fake内の保存・復元](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Support/Testing/Fakes/QueueFake.php)
* [Laravel公式テスト: モデルのシリアライズ](https://github.com/laravel/framework/blob/v13.42.0/tests/Integration/Queue/ModelSerializationTest.php)


## Related topics

- [Orchestra TestbenchでLaravelパッケージをテストする](https://kawax.biz/jp/advanced/package-testing.md)
- [応用トピック](https://kawax.biz/jp/advanced/index.md)
- [パッケージの公開アセットと更新](https://kawax.biz/jp/advanced/package-assets.md)
- [パッケージのマイグレーション公開と更新](https://kawax.biz/jp/advanced/package-migrations.md)
- [パッケージビューの上書きと更新](https://kawax.biz/jp/advanced/package-views.md)


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