【実務完全ガイド】Laravelのスケジューラとバッチ処理――定期実行、集計、通知、外部連携、排他制御、失敗復旧、アクセシブルな運用UI
この記事で学べること(要点)
- Laravel Scheduler を使って、cron を増やさずに定期実行を一元管理する方法
- 日次集計、定期通知、期限切れ処理、データ同期、ログ整理などの代表的なバッチ処理設計
withoutOverlapping、onOneServer、ロック、冪等性で二重実行や取りこぼしを防ぐ実務パターン- バッチの失敗時対応、再実行、アラート、監査ログ、運用Runbookの整え方
- 重い処理を Scheduler から Queue へ渡して、安全に分散実行する考え方
- 管理画面での実行履歴、進捗表示、完了通知をアクセシブルに見せる設計
- テストと本番運用まで含めた、壊れにくい定期処理基盤の作り方
想定読者(だれが得をする?)
- Laravel 初〜中級エンジニア:cron に処理を書き散らしてしまい、整理したい方
- テックリード/運用担当:日次バッチや外部同期を、見える形で安全運用したい方
- PM/CS/バックオフィス担当:通知や締切処理が遅れず回る仕組みを整えたい方
- QA/アクセシビリティ担当:バッチ結果の表示や失敗通知を、だれでも理解しやすいUIへ整えたい方
アクセシビリティレベル:★★★★★
実行結果の通知、進捗、失敗案内、再実行導線を
role="status"/role="alert"、見出し構造、色に依存しない状態表示、キーボードで操作可能な管理画面という観点で具体化します。
1. はじめに:定期実行は「動く」だけでは足りません
Laravelで開発を続けていると、ある時点で必ず「毎日決まった時間に動かしたい処理」が増えてきます。たとえば、売上集計、請求書の発行、期限切れデータの整理、会員への通知、外部サービスとの同期、キャッシュの更新、ログの掃除などです。最初はサーバの cron に直接コマンドを書いても動きますが、数が増えるほど、どこで何が動いているのか分からない状態になりやすいです。
しかも、定期実行は失敗してもその場で気づきにくいという難しさがあります。画面のように目の前でエラーが出るわけではないので、「昨日の集計が回っていなかった」「通知が送られていなかった」「同じ処理が二重実行されていた」といった問題が、後から見つかりがちです。ですので、バッチ処理はスケジュール登録・排他制御・監視・通知・再実行まで含めて設計した方が安心です。
Laravel の Scheduler は、この一連の管理をかなり分かりやすくしてくれます。この記事では、初学者の方でも迷いにくいように、定期実行の基本から、実務で重要になる排他・再試行・可観測性・管理画面のUIまで、順番に整理してまいりますね。
2. まず理解したい:Laravel Scheduler は「cronの整理役」です
サーバには通常、cron という定期実行の仕組みがあります。Laravel Scheduler の考え方は、とてもシンプルです。サーバの cron は1本だけにして、その中から Laravel アプリが「今実行すべき処理」を判断して動かします。
つまり、cron に何十行も登録するのではなく、Laravel 側の routes/console.php や app/Console/Kernel.php に「毎日何時」「毎分」「毎週月曜」などのルールを集約するイメージです。こうすると、コードレビュー、環境差分の管理、テスト、監視の導線がぐっと整います。
Laravel のスケジューラが向いているのは、次のようなケースです。
- 毎日・毎時・毎週のような定期処理
- 実行条件に応じて「今日はスキップする」などの分岐がある処理
- 実行ログや通知をアプリ内で一貫管理したい処理
- キューやジョブと組み合わせて、重い処理を安全に分散したい処理
反対に、OSレベルの監視やDBバックアップなど、Laravel アプリの外にある処理は、無理に全部 Scheduler に寄せなくても大丈夫です。アプリケーション責務の範囲に収める、という考え方が扱いやすいです。
3. 基本設定:cron は1本、処理の定義は Laravel 側へ集約します
まず本番サーバでは、cron に次のような1行を登録します。
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
この1行が、毎分 Laravel に「今の時刻で実行すべきものはある?」と問い合わせる役目を果たします。以後の処理定義は Laravel 側にまとめます。
Laravel 11 系以降では routes/console.php に書く形が扱いやすいです。例として、毎日 2:00 に売上集計を実行する場合は、次のように書けます。
use Illuminate\Support\Facades\Schedule;
Schedule::command('report:daily-sales')
->dailyAt('02:00');
このシンプルさが Scheduler の魅力です。ファイルを見れば、何がどの周期で回るかが分かります。レビューでも「この処理は毎日2時に回るのですね」と一目で確認できますし、環境の再現性も高まります。
4. 代表的なバッチ処理:最初に整理しておきたい4分類
定期処理は多種多様ですが、実務では次の4つに分けると管理しやすいです。
4.1 集計系
- 日次売上集計
- アクティブユーザー数の集計
- 月次レポート生成
- ダッシュボード用の素材テーブル更新
これらは「結果が画面やレポートに使われる」ことが多く、多少遅れても致命的ではない一方、毎日確実に揃うことが大切です。
4.2 通知系
- 締切前リマインド
- 請求書発行通知
- パスワード期限警告
- エクスポート完了通知
通知はユーザー影響が見えやすいので、実行漏れや二重送信に特に注意が必要です。
4.3 同期・連携系
- 外部APIからのデータ同期
- 会計/CRM/MAツールとの連携
- 在庫情報や配送情報の取り込み
外部要因で失敗しやすく、再試行や冪等性がとても重要になります。
4.4 整理・保守系
- 一時ファイル削除
- 古いログ削除
- 期限切れトークンの掃除
- ステータス更新(予約公開、失効処理)
この手の処理は「静かに動いていてほしい」ため、止まっても気づきにくいです。だからこそ、監視や実行履歴が大切です。
5. コマンドとジョブ:Scheduler から直接重い処理をしない方が安全です
初学者の方がやりがちなのが、Scheduler から重い処理をそのまま全部実行してしまうことです。もちろん小さな処理なら問題ありませんが、データ件数が増えるとタイムアウトやメモリ不足の原因になります。そこでおすすめなのが、Scheduler は起点だけを担当し、実際の重い処理は Job へ渡す設計です。
5.1 Artisan Command を作る
php artisan make:command DailySalesReportCommand
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Jobs\GenerateDailySalesReport;
class DailySalesReportCommand extends Command
{
protected $signature = 'report:daily-sales';
protected $description = '日次売上レポートを生成します';
public function handle(): int
{
GenerateDailySalesReport::dispatch(now()->subDay()->toDateString());
$this->info('日次売上レポートの生成ジョブを投入しました。');
return self::SUCCESS;
}
}
5.2 Job 側で実処理を担う
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\SerializesModels;
class GenerateDailySalesReport implements ShouldQueue
{
use Dispatchable, Queueable, SerializesModels;
public function __construct(public string $targetDate) {}
public int $tries = 5;
public int $timeout = 300;
public function handle(): void
{
// 集計処理
}
}
この形にしておくと、Scheduler 側は軽く、重い処理は Queue/Horizon で監視できるようになります。運用でも「スケジュールは実行されたが、ジョブが失敗した」といった切り分けがしやすくなります。
6. 排他制御:二重実行を防ぐ withoutOverlapping は最初に覚えたいです
定期処理でとても怖いのが、前回処理が終わっていないのに次回が始まってしまうことです。たとえば、毎分の同期処理が90秒かかる場合、放っておくと次の1分でまた起動し、重複処理や競合が起きます。
Laravel には、このための便利なメソッドがあります。
Schedule::command('sync:external-orders')
->everyMinute()
->withoutOverlapping();
これを付けると、前回処理がまだ終わっていれば次回実行を抑止してくれます。非常に便利ですが、万能ではありません。次の点は理解しておくと安心です。
- ロックの有効期限を必要に応じて調整したいことがある
- 処理時間が極端に長い場合は、そもそも毎分にしない方が安全なこともある
- 重い処理は Scheduler でなく Job 化し、キューで管理した方が見通しが良いことが多い
つまり、withoutOverlapping は強力ですが、「定義するだけで安心」ではなく、実行時間や設計の見直しとセットで考えるのが実務的です。
7. 複数サーバ構成では onOneServer が重要です
本番でアプリサーバが複数台ある場合、それぞれのサーバで schedule:run が動いていると、同じ処理が台数分だけ起動してしまう可能性があります。これを防ぐのが onOneServer() です。
Schedule::command('billing:issue-invoices')
->dailyAt('01:00')
->onOneServer();
これにより、複数台構成でも1台だけがそのジョブを実行します。請求書発行や一括通知のような処理では、特に重要です。もちろん、これもロック機構や共有キャッシュストア(Redis など)が正しく動いている前提なので、環境設定とセットで確認しておきたいところです。
8. 冪等性:失敗からの再実行に強くする設計が必要です
Scheduler と Queue を使うと、「失敗したから再実行する」が現実的になります。ですが、ここで設計が甘いと、同じメールが二通送られたり、同じ請求書が二重発行されたりします。そこで重要なのが冪等性です。
たとえば、請求書発行処理なら次のように考えます。
- すでに対象期間の請求書が発行済みなら、新しく作らない
- 発行済みフラグや対象年月の一意制約で重複を防ぐ
- 実行途中で失敗しても、再実行時に整合が崩れないようにする
例:
$invoice = Invoice::firstOrCreate(
[
'customer_id' => $customer->id,
'billing_month' => $billingMonth,
],
[
'status' => 'issued',
'total_amount' => $amount,
]
);
このように「同じ入力なら同じ結果になる」設計にしておくと、Scheduler でも Queue でも安心して再実行できます。バッチ処理では、この考え方がとても大切です。
9. 実行条件を柔らかく制御する:when と skip を活用します
定期処理は、毎回必ず実行すればよいわけではありません。たとえば次のような条件があります。
- 営業日だけ実行したい
- 本番環境だけ実行したい
- 月末だけ動かしたい
- Feature Flag がONのときだけ動かしたい
Laravel では when() や skip() でこれを自然に書けます。
Schedule::command('report:monthly')
->monthlyOn(1, '03:00')
->when(fn () => app()->environment('production'));
Schedule::command('notify:trial-expiring')
->dailyAt('10:00')
->skip(fn () => now()->isWeekend());
このようにしておくと、「if文だらけのコマンド」を作らずに済みますし、スケジュール定義を読むだけで挙動が理解しやすくなります。
10. 結果通知:成功も失敗も、静かに分かるようにします
バッチ処理は、終わったことが見えにくいので、通知設計が大切です。ただし、毎回成功通知を出しすぎるとノイズになります。おすすめは、次のような整理です。
- 毎日成功するのが前提の処理
- 基本はログだけで良い
- 失敗時だけ通知
- 人が待っている処理(エクスポート、月次レポート)
- 開始通知と完了通知があると親切
- 重要処理(請求、外部同期)
- 成功/失敗を監視し、急増時だけアラート
10.1 UIへの表示例
@if(session('status'))
<div role="status" aria-live="polite" class="border p-3 mb-4">
{{ session('status') }}
</div>
@endif
@if(session('error'))
<div role="alert" class="border p-3 mb-4">
{{ session('error') }}
</div>
@endif
ここでのポイントは、色だけで成功/失敗を示さないことです。「レポート生成を開始しました」「同期に失敗しました。再実行してください」のように、テキストで意味が分かることが重要です。
11. バッチ実行履歴:管理画面で見える化すると運用が安定します
定期処理が増えてくると、「今朝の集計は成功したのか」「昨日の同期は何件取り込んだのか」を画面で確認したくなります。そこで、実行履歴テーブルを持っておくと便利です。
例:batch_runs テーブルの項目
name(処理名)status(success / failed / running)started_atfinished_atmessagemeta(対象日付、件数など)trace_id
コマンドやジョブの開始時に running を作り、成功/失敗で更新します。これがあるだけで、管理画面で「最近の実行状況」が見えるようになり、サポートや運用担当との会話もスムーズになります。
11.1 一覧UIの例
- 処理名
- 対象日
- 状態
- 開始時刻
- 終了時刻
- 件数
- 詳細リンク
状態は「成功」「失敗」「実行中」を文字で表示し、色は補助として使います。ここでもアクセシビリティの基本は同じです。
12. 管理画面のアクセシビリティ:運用UIだからこそ大切です
運用担当の方は、管理画面を長時間使うことが多いです。ですので、一般ユーザー向け画面以上に「疲れにくく、誤操作しにくい」UIが価値を持ちます。バッチ履歴や再実行画面で特に意識したいのは、次の点です。
- 見出し構造がある
- 表は
<table>として正しく作る - 状態は色だけに依存しない
- 再実行ボタンの意味が明確
- 実行中・成功・失敗の通知は
role="status"/role="alert"で伝える - キーボードだけで一覧→詳細→再実行まで完了できる
たとえば、再実行ボタンには「再実行」だけでなく、「2026-03-31 の売上集計を再実行」のような補足が見えると、誤操作が減ります。視覚情報に頼らず、文言だけでも意味が通る設計が理想です。
13. 失敗時の復旧導線:Runbook を短く持っておくと安心です
バッチ処理が失敗したとき、毎回その場で考えるのは負担が大きいです。だからこそ、短くてもよいので Runbook を用意しておくと強いです。最低限、次のような流れを決めておくと実務で役立ちます。
- 影響範囲の確認(どの処理、どの対象日、どの機能)
- ログの確認場所(trace_id、バッチ履歴、ジョブログ)
- 再実行の条件(そのまま再実行でよいか、データ修正が必要か)
- ユーザー影響の案内(必要ならCSや管理者へ共有)
- 恒久対処(コード修正、監視追加、テスト追加)
バッチは「失敗しない」より、「失敗しても落ち着いて戻せる」ことが重要です。Runbook があるだけで心理的な負担も大きく下がります。
14. テスト:Scheduler 自体より、ジョブと条件分岐を守ります
Scheduler はフレームワーク側の機能が多いので、全部を重くE2Eで確認するより、次を重点的に守ると効果が高いです。
- コマンドが正しくジョブを投入する
- Job が冪等に動く
- 条件分岐(営業日だけ、月末だけ)が期待どおり
- 失敗時に履歴やログが残る
14.1 コマンドがジョブを投入するテスト
use Illuminate\Support\Facades\Queue;
public function test_daily_sales_command_dispatches_job()
{
Queue::fake();
$this->artisan('report:daily-sales')
->assertExitCode(0);
Queue::assertPushed(\App\Jobs\GenerateDailySalesReport::class);
}
14.2 冪等性のテスト
「同じ対象日で2回実行しても、レポートが二重作成されない」ようなテストが、実務ではとても効きます。
15. よくある落とし穴と回避策
- Scheduler に重い処理を直接書いてしまう
- 回避:起点だけ Scheduler、実処理は Job 化
- 二重実行で通知や請求が重複する
- 回避:
withoutOverlapping、onOneServer、冪等性
- 回避:
- 成功/失敗が見えず、止まっていても気づけない
- 回避:実行履歴、Horizon、失敗通知、アラート
- cron の設定漏れで何も動いていない
- 回避:デプロイ手順書とヘルスチェック
- 外部API障害で毎回失敗し続ける
- 回避:再試行上限、バックオフ、失敗隔離
- 管理画面の状態表示が色だけ
- 回避:テキストラベルを必ず付ける
- 再実行ボタンが危険すぎる
- 回避:対象範囲を明示し、必要なら確認導線を入れる
16. チェックリスト(配布用)
スケジューラ設計
- [ ] cron は
schedule:runの1本に集約している - [ ] 定期処理が Laravel 側で一覧できる
- [ ] 本番のみ実行などの条件が明確に書かれている
安全性
- [ ]
withoutOverlappingを必要な処理に付けている - [ ] 複数台構成では
onOneServerを検討している - [ ] 冪等性(重複作成防止、送信済み判定)がある
非同期化
- [ ] 重い処理は Command から Job に渡している
- [ ] Queue/Horizon で遅延や失敗を監視している
- [ ] 再試行回数、timeout、backoff が明示されている
可観測性
- [ ] 実行履歴テーブルやログで成否が追える
- [ ] 重要処理に trace_id や対象日が残る
- [ ] 失敗時の通知・アラートがある
UI/アクセシビリティ
- [ ] 実行状態をテキストで示している
- [ ]
role="status"/role="alert"を適切に使っている - [ ] 再実行導線がキーボードで使える
- [ ] 状態表示が色だけに依存していない
テスト
- [ ] コマンドの投入テストがある
- [ ] Job の冪等性テストがある
- [ ] 失敗時のログや履歴を確認するテストがある
17. まとめ
Laravel の Scheduler は、定期実行を「見える形で」管理できる、とても実務的な仕組みです。ただし、ただ登録するだけでは十分ではありません。重い処理は Queue に分け、排他制御で二重実行を防ぎ、冪等性で再実行に耐え、実行履歴と通知で“止まったことに気づける”状態まで整えることが大切です。さらに、運用画面や再実行UIをアクセシブルに作ることで、管理担当の負担も誤操作も減らせます。まずは1つの定期処理から、Scheduler・Job・履歴・通知の4点セットで整えてみてくださいませ。そこから全体へ広げると、静かに強い運用基盤へ育っていきます。

