> ## 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のScheduleと解決後フックを読み解き、パッケージの定期タスクを明示的に有効化する設計、二重登録、ロック名、設定変更とテストを解説します。

パッケージに定期的な同期やHeartbeatを追加するには、**処理の提供**と**スケジュールへの登録**を分けます。インストールしただけで利用アプリケーションの定期処理を増やさず、利用者が有効化・実行頻度・複数サーバーでの動作を選べる設計にします。

このページではLaravel Framework `v13.39.0` の公式ソースを確認し、[タスクスケジューリング](https://kawax.biz/jp/scheduling.md)の基本操作をパッケージ開発に適用します。プロバイダーの読み込みは[パッケージの自動検出](https://kawax.biz/jp/advanced/package-discovery.md)、設定の公開は[パッケージ設定のマージとキャッシュ](https://kawax.biz/jp/advanced/package-config-merging.md)を参照してください。

## Scheduleの解決後に登録する

通常のLaravelアプリケーションでは、`FoundationServiceProvider` が `Schedule::class` をsingletonとして登録します。その生成処理はConsole Kernelの `resolveConsoleSchedule()` を呼び、アプリケーションのタイムゾーンとスケジューラ用キャッシュストアを適用します。

パッケージはこの共有Scheduleを拡張し、自分用の `new Schedule()` を作って終わりにしないようにします。次のプロバイダーは、`Acme\Courier\` がComposerのPSR-4で `src/` に対応し、パッケージに `config/courier.php` がある構成です。

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

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/courier.php', 'courier');

        $this->app->singleton(CourierSchedule::class);
    }

    public function boot(): void
    {
        if (! $this->app->runningInConsole()) {
            return;
        }

        $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
            $this->app->make(CourierSchedule::class)->register($schedule);
        });
    }
}
```

`callAfterResolving()` は、未解決なら今後の解決を待ち、すでに解決済みなら取得してその場でもコールバックを呼びます。これにより、別のプロバイダーが先にScheduleを使った場合も登録できます。詳細は[サービス解決後フック](https://kawax.biz/jp/advanced/package-service-resolution.md)を参照してください。

`runningInConsole()` は特定のArtisanコマンドの判定ではありません。この例ではHTTP経由でScheduleを使う用途を対象外にします。また、フックを登録するプロバイダー自体は遅延プロバイダーにしません。必要な時点までに `boot()` が動かなければ、フックも登録されないためです。

```mermaid theme={null}
flowchart TD
    A["パッケージプロバイダーのboot()"] --> B["Scheduleの解決後フックを登録"]
    B --> C{"Scheduleは解決済み?"}
    C -->|No| D["初回解決まで待つ"]
    C -->|Yes| E["既存のsingletonを取得"]
    D --> F["有効化設定と登録済み判定"]
    E --> F
    F -->|有効かつ未登録| G["イベントの定義を追加"]
    F -->|無効または登録済み| H["追加しない"]
    G --> I["後のschedule:runで<br>実行条件を評価"]
```

> [!NOTE]
>
> イベントの登録とタスクの実行は別です。このプロバイダーを読み込んでも、その場でHeartbeatを実行するわけではありません。登録だけではcronや `schedule:work` も起動しません。

## 利用者が明示的に有効化する

パッケージ側の `config/courier.php` は、初期状態を無効にします。

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

return [
    'schedule' => [
        'enabled' => false,
        'cron' => '*/5 * * * *',
        'timezone' => 'UTC',
        'overlap_minutes' => 10,
        'on_one_server' => false,
    ],
];
```

利用アプリケーションでは設定を公開・作成し、`schedule.enabled` を `true` に変更します。この例では値を登録時に読み取ります。タイムゾーンはUTCを初期値とし、別のタイムゾーンを選ぶ場合は夏時間による実行の重複・欠落も考慮します。

設定は[浅くマージされる](https://kawax.biz/jp/advanced/package-config-merging.md)ため、登録器でも各キーの既定値を指定します。部分的な上書きで `schedule` 配列のほかのキーがなくなっても、基本設定で登録できます。

## 同じScheduleへの二重登録を防ぐ

次の `src/CourierSchedule.php` は、ログ1件を記録する最小のHeartbeatを登録します。`WeakMap` により、**この登録器が同じScheduleオブジェクトへ追加するのは1回だけ**にします。

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

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Config\Repository;
use Psr\Log\LoggerInterface;
use WeakMap;

final class CourierSchedule
{
    public const EVENT_NAME = 'acme-courier:heartbeat';

    private WeakMap $registered;

    public function __construct(private Repository $config)
    {
        $this->registered = new WeakMap();
    }

    public function register(Schedule $schedule): void
    {
        if (! $this->config->get('courier.schedule.enabled', false)
            || isset($this->registered[$schedule])) {
            return;
        }

        $event = $schedule->call(static function (LoggerInterface $logger): void {
            $logger->info('Courier heartbeat');
        })
            ->name(self::EVENT_NAME)
            ->cron($this->config->get('courier.schedule.cron', '*/5 * * * *'))
            ->timezone($this->config->get('courier.schedule.timezone', 'UTC'))
            ->withoutOverlapping($this->config->get('courier.schedule.overlap_minutes', 10));

        if ($this->config->get('courier.schedule.on_one_server', false)) {
            $event->onOneServer();
        }

        $this->registered[$schedule] = true;
    }
}
```

この登録器をsingletonにしているので、プロバイダーのフックから再度取得しても登録済み判定を共有します。一方、新しいScheduleには登録できます。プロセス全体の静的な「登録した」フラグで、新しいコンテナやテストのScheduleまで除外しないようにします。

`Schedule::call()`・`exec()`・`job()` はイベントを配列へ追加します。同じ名前のイベントを見つけて置換したり、重複排除したりするAPIではありません。

> [!WARNING]
>
> `withoutOverlapping()` は登録の重複を修正しません。同じタスクが2件登録され、先の実行が終了してロックが解放されれば、後のイベントも実行され得ます。`onOneServer()` も二重登録を除去する機能ではありません。
>
> 上のWeakMapは、この登録器の重複だけを防ぎます。利用者が `routes/console.php` に同じタスクを追加する場合は、パッケージの自動登録を無効にし、登録の担当を1か所にします。

## イベント名はロックの互換性にも関わる

`name()` は `description()` の別名ですが、ロック名への影響はイベントの種類で異なります。以下は既定のmutex名です。

| イベント | mutex名の元になる値 | `name()` の役割 |
| - | - | - |
| `command()`・`exec()` のEvent | cron式と正規化したコマンド文字列 | 表示用の説明。既定のmutex名には含まれない |
| `call()`・`job()` のCallbackEvent | イベントのdescription | 表示名とロックの識別。cron式は含まれない |

CallbackEventでは、`withoutOverlapping()` や `onOneServer()` より**先に空でない名前**を付けます。名前が未設定なら、これらのメソッドは例外を投げます。

`job()` は通常、ジョブクラス名などを初期名にします。ただし、同じジョブを異なる引数・対象で登録する場合は、対象ごとの名前を明示します。引数だけを変えても、初期名が自動的に別名になるわけではありません。

```php theme={null}
use Acme\Courier\Jobs\SyncCatalogJob;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new SyncCatalogJob('tenant-a'))
    ->name('acme-courier:catalog:tenant-a')
    ->everyFiveMinutes()
    ->onOneServer();

Schedule::job(new SyncCatalogJob('tenant-b'))
    ->name('acme-courier:catalog:tenant-b')
    ->everyFiveMinutes()
    ->onOneServer();
```

`SyncCatalogJob` はパッケージが提供するジョブクラスの例です。ジョブの実装方法は[キュー](https://kawax.biz/jp/queues.md)を参照してください。

名前やコマンド、cron式を変更すると、種類によってロックの識別子も変わります。旧版と新版のプロセスが混在するデプロイでは、同じ処理でも別のロックを取得する可能性があります。単なる表示文言の変更と同じ扱いにせず、タスク名を安定した公開仕様として管理します。

### onOneServerとwithoutOverlappingを分けて考える

* `onOneServer()` は、その実行時刻に実行を担当するサーバーを選びます。確認した `CacheSchedulingMutex` は、イベントのmutex名に時分を加え、1時間の有効期限で保持します。タスク終了に合わせて解放する重複防止ロックとは別です。
* `withoutOverlapping()` は、前の実行が続いている間の重複を防ぎます。指定する期限の単位は分で、既定は1,440分です。短すぎる期限では、処理が終わる前にロックが失効します。
* `job()` がキューへ投入する場合、スケジュールイベントの完了はジョブの処理完了ではありません。ワーカーでの重複制御は、ジョブ側のユニークジョブや `WithoutOverlapping` ミドルウェアなども別に設計します。

`onOneServer()` を有効にする利用者には、公式ドキュメントが指定する `database`・`memcached`・`dynamodb`・`redis` の共有キャッシュ構成を必要条件として案内します。ロックは業務処理のexactly-onceを保証するものではなく、再実行されても壊れない処理も必要です。

## 重い依存関係は実行時に解決する

上のHeartbeatの `LoggerInterface` は、イベント実行時に `CallbackEvent` がコンテナの `call()` を使って解決します。フックの登録時にログを出したり、クライアントを生成したりしない構成です。

| 定義方法 | 登録時の注意点 |
| - | - |
| `call()` にクロージャを渡す | 本体は後で呼ばれる。ただし、外側で生成してcaptureしたオブジェクトは生成済み |
| `call(new Task)` | `new Task` は登録時に実行される |
| `command(CommandClass::class)` | Scheduleがコンテナでクラスを生成し、名前と説明を取得する |
| `job(JobClass::class)` | ジョブの生成はイベントのコールバック内、実行時まで待つ |

コンストラクターに外部接続や業務処理を置かない設計は、[Artisanコマンドの依存解決](https://kawax.biz/jp/advanced/package-commands.md)でも重要です。

また、`call()` と `job()` はCallbackEventなので、`runInBackground()` は使えません。時間のかかる処理はキューに委譲するか、必要に応じて `command()` のバックグラウンド実行を選びます。

## アプリケーション全体の設定を奪わない

`Schedule::useCache()` はScheduleが持つmutexオブジェクトのストアを変更します。パッケージのイベントだけを対象にする設定ではなく、同じmutexを共有するイベントにも影響します。パッケージの `boot()` で無条件に呼ばず、利用アプリケーションが選ぶ設定として扱います。

同様に、`Schedule::alwaysOnOneServer()`・スケジューラの一時停止・メンテナンスモード中の実行方針を、パッケージが勝手に変更しないようにします。

## 設定変更後はScheduleを新しく作る

例の `enabled`・cron式・タイムゾーンは登録時の設定です。生成済みScheduleに対して `config()` の値を変えるだけでは、イベントが追加・削除されたり、設定が読み直されたりしません。WeakMapの判定も再設定を行うためのものではありません。

設定キャッシュを利用するデプロイではキャッシュを再構築し、新しい設定を読み込むプロセスで `schedule:list` を確認します。長時間動くスケジューラや、1分未満のタスクを実行中の `schedule:run` についても、旧コード・旧設定が残らない運用を確認します。サブ分タスクのデプロイ時の中断は[タスクスケジューリング](https://kawax.biz/jp/scheduling.md)を参照してください。

## 登録を実行から分けてテストする

次は、パッケージのComposerオートロードとPHPUnitを使う `tests/CourierScheduleTest.php` の例です。mutexをテストダブルにし、外部キャッシュや実際のcronを使わずに登録結果を確認します。

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

namespace Acme\Courier\Tests;

use Acme\Courier\CourierSchedule;
use Illuminate\Config\Repository;
use Illuminate\Console\Scheduling\EventMutex;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\SchedulingMutex;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

class CourierScheduleTest extends TestCase
{
    private Container $previousContainer;
    private Repository $config;
    private CourierSchedule $registrar;

    protected function setUp(): void
    {
        parent::setUp();

        $this->previousContainer = Container::getInstance();
        $container = new Container();
        Container::setInstance($container);

        $container->instance(EventMutex::class, $this->createMock(EventMutex::class));
        $container->instance(SchedulingMutex::class, $this->createMock(SchedulingMutex::class));

        $this->config = new Repository();
        $this->registrar = new CourierSchedule($this->config);
    }

    protected function tearDown(): void
    {
        Container::setInstance($this->previousContainer);

        parent::tearDown();
    }

    public function test_disabled_by_default(): void
    {
        $schedule = new Schedule();
        $this->registrar->register($schedule);

        $this->assertCount(0, $schedule->events());
    }

    public function test_partial_config_uses_defaults_without_duplicate_registration(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $schedule = new Schedule();

        $this->registrar->register($schedule);
        $this->registrar->register($schedule);

        $this->assertCount(1, $schedule->events());
        $event = $schedule->events()[0];
        $this->assertSame(CourierSchedule::EVENT_NAME, $event->description);
        $this->assertSame('*/5 * * * *', $event->expression);
        $this->assertSame('UTC', $event->timezone);
        $this->assertTrue($event->withoutOverlapping);
        $this->assertSame(10, $event->expiresAt);
        $this->assertFalse($event->onOneServer);
    }

    public function test_explicit_schedule_settings_are_applied(): void
    {
        $this->config->set('courier.schedule', [
            'enabled' => true,
            'cron' => '0 * * * *',
            'timezone' => 'Asia/Tokyo',
            'overlap_minutes' => 30,
            'on_one_server' => true,
        ]);

        $schedule = new Schedule();
        $this->registrar->register($schedule);
        $event = $schedule->events()[0];

        $this->assertSame('0 * * * *', $event->expression);
        $this->assertSame('Asia/Tokyo', $event->timezone);
        $this->assertSame(30, $event->expiresAt);
        $this->assertTrue($event->onOneServer);
    }

    public function test_setting_changes_require_a_new_schedule(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $first = new Schedule();
        $this->registrar->register($first);

        $this->config->set('courier.schedule.cron', '0 * * * *');
        $this->registrar->register($first);

        $second = new Schedule();
        $this->registrar->register($second);

        $this->assertCount(1, $first->events());
        $this->assertCount(1, $second->events());
        $this->assertSame('*/5 * * * *', $first->events()[0]->expression);
        $this->assertSame('0 * * * *', $second->events()[0]->expression);
    }
}
```

このテストは登録器の単体テストです。プロバイダーの自動検出・設定マージ・Console Kernelへの統合は、[Orchestra Testbench](https://kawax.biz/jp/advanced/package-testing.md)や検証用アプリケーションで別に確認します。

| 確認するケース | 完了条件 |
| - | - |
| 初期設定で導入 | パッケージの定期タスクが登録されない |
| 有効化して `schedule:list` | イベントが1件だけあり、頻度とタイムゾーンが正しい |
| Scheduleの解決前・解決後にプロバイダーを起動 | どちらも登録が反映される |
| 登録だけを行う | 業務サービスの生成・処理が始まらない |
| タスク本体を個別に実行 | 必要な依存を解決し、期待する処理・例外を確認できる |
| 複数サーバーと長時間処理 | 共有キャッシュの前提と、2種類のロックを別に検証する |
| 設定キャッシュありの更新 | 新しいプロセスで旧タスクや二重登録が残らない |

登録テストを通すために `schedule:run` で利用アプリケーションの全タスクを実行しないようにします。本体のテストと、制御した環境でのスケジューラ統合テストを分けることで、無関係な定期処理の副作用を避けられます。

## 関連ページ

> [card] [タスクスケジューリング](https://kawax.biz/jp/scheduling.md)
>
> 実行頻度、cronの導入、キューへの委譲、デプロイ時の運用を確認します。

> [card] [パッケージのサービス解決後フック](https://kawax.biz/jp/advanced/package-service-resolution.md)
>
> 未解決・解決済みサービスへの適用と、再登録時の注意点を確認します。

## 参照した一次情報

* [Laravel公式ドキュメント: タスクスケジューリング](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/scheduling.md)
* [Laravel公式ドキュメント: パッケージ開発](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md)
* [FoundationServiceProvider: Scheduleのsingleton登録](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Console Kernel: Scheduleの生成と既定の設定](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Console/Kernel.php)
* [ServiceProvider: callAfterResolving()](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Schedule: イベントの追加、ジョブの遅延生成、mutexの共有](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Schedule.php)
* [Event: コマンドイベントのmutex名](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Event.php)
* [CallbackEvent: 名前の必須条件と実行時の依存解決](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CallbackEvent.php)
* [CacheEventMutex: 重複防止ロックと期限の変換](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheEventMutex.php)
* [CacheSchedulingMutex: 実行時刻ごとのロック](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php)
* [公式テスト: イベントの登録とタイムゾーン](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/ConsoleEventSchedulerTest.php)
* [公式テスト: ジョブクラス名の遅延生成](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/Scheduling/ScheduleTest.php)


## Related topics

- [タスクスケジューリング](https://kawax.biz/jp/scheduling.md)
- [応用トピック](https://kawax.biz/jp/advanced/index.md)
- [パッケージのStorageドライバー登録と再解決](https://kawax.biz/jp/advanced/package-filesystem-drivers.md)
- [パッケージのキャッシュとoptimizeへの統合](https://kawax.biz/jp/advanced/package-optimization.md)
- [Laravelパッケージ開発](https://kawax.biz/jp/advanced/package-development.md)


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