【完全保存版】Laravel スケジューラ&キュー連携で作る定期処理・バッチ運用ガイド――安定実行・監視・アクセシブルな進捗通知まで
この記事で学べること(要点)
- Laravel のスケジューラ(
app/Console/Kernel.php
)で定期処理を安全に運用する設計 - キュー(
ShouldQueue
/再試行/遅延)と組み合わせた大規模バッチの実装・監視 - 冪等性・重複実行防止・タイムゾーン・メンテナンス時間帯の扱い
- 成果物(レポート生成、インポート、通知)におけるアクセシビリティ配慮(読み上げ、フォーカス、色に依存しない状態表現)
- 運用チェックリスト、失敗時のロールバック、CI/CD への組み込み
想定読者(だれが得をする?)
- 実務で定期バッチ・夜間処理を動かしたい Laravel 初〜中級エンジニア
- 受託や自社 SaaS のテックリード:キューとスケジューラで安定運用の型を作りたい方
- CS/運用チーム:ユーザー通知や失敗検知を整備し、問い合わせを減らしたい方
- アクセシビリティ担当・QA:進捗表示・完了報告・障害時の案内を「誰でも理解できる」形に整えたい方
1. はじめに:なぜ「スケジューラ×キュー」なのか
業務システムや SaaS では、夜間バッチ、毎時の集計、毎朝のメール配送など「決まったタイミングで確実に走る処理」が欠かせません。Laravel のスケジューラは OS の cron を一つだけ登録し、アプリ側で柔軟に発火条件を管理できる仕組みです。大量データを扱う処理や外部 API 連携には、ジョブキューと組み合わせるのが定石。これにより、以下を実現します。
- アプリコードで時間・頻度・例外時の対応を一元管理
- 各処理をジョブとして非同期化し、再試行やワーカー分散に対応
- 失敗時のアラートやユーザー向けのお知らせを設計しやすい
- 将来のスケールに備え、処理を細分化・直列/並列で構成可能
本記事は「安全に、静かに、確実に動く」定期処理の設計・実装・運用を、アクセシビリティ視点も含めて丁寧に解説します。
2. 基本の配線:cron は 1 行、スケジューラはアプリ側で管理
2.1 cron の設定(サーバー側)
* * * * * php /path/to/artisan schedule:run >> /dev/null 2>&1
- 1 分ごとに
schedule:run
を実行すると、app/Console/Kernel.php
に定義したスケジュールが必要な時だけ走ります。 - 複数サーバーで同じ cron を入れる場合は、ロック(後述)や「リーダーだけ実行」戦略を取りましょう。
2.2 app/Console/Kernel.php
の基本
// app/Console/Kernel.php
namespace App\Console;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
{
protected function schedule(Schedule $schedule): void
{
// 毎日 03:00 に売上レポートを生成
$schedule->command('report:daily-sales')
->dailyAt('03:00')
->timezone('Asia/Tokyo')
->withoutOverlapping(30) // 前回実行が残っていたらスキップ(30分ロック)
->onOneServer() // マルチサーバー時は単一実行
->emailOutputOnFailure('ops@example.com');
}
protected function commands(): void
{
$this->load(__DIR__.'/Commands');
}
}
要点:
timezone('Asia/Tokyo')
を明示し、時差や夏時間の影響を避けます。withoutOverlapping()
で二重起動を防止。onOneServer()
は分散環境で単一ノードのみ実行するための保険。emailOutputOnFailure()
で失敗を検知し運用へ通知(他の通知チャネルも可)。
3. Artisan コマンドとキュー:重い処理は必ず非同期化
3.1 コマンドを作る(薄い orchestration)
php artisan make:command GenerateDailySalesReport
// app/Console/Commands/GenerateDailySalesReport.php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Jobs\BuildSalesReport; // 後述
class GenerateDailySalesReport extends Command
{
protected $signature = 'report:daily-sales {--date=}';
protected $description = '日次売上レポートを生成';
public function handle(): int
{
$date = $this->option('date') ?? now()->subDay()->toDateString();
BuildSalesReport::dispatch($date)->onQueue('reports');
$this->info("Dispatched BuildSalesReport for {$date}");
return Command::SUCCESS;
}
}
- コマンドはオーケストレーションに徹し、重い処理はジョブへ。
- オプションで対象日を指定できるようにして、後から再実行しやすくします。
3.2 ジョブを作る(ビジネス処理の本体)
php artisan make:job BuildSalesReport
// app/Jobs/BuildSalesReport.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
use App\Services\Reports\SalesReportService;
class BuildSalesReport implements ShouldQueue
{
use InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3; // 自動再試行回数
public int $timeout = 3600; // タイムアウト 1h
public function __construct(public string $date) {}
public function middleware(): array
{
return [
new RateLimited('reports'), // 外部 API 等への呼び出し制限名
];
}
public function handle(SalesReportService $svc): void
{
// 冪等チェック(既に生成済みならスキップ)
if ($svc->exists($this->date)) {
return;
}
// 大量データはチャンクで取得し、CSV/Excel/PDF を生成
$svc->build($this->date);
// 完了通知(メール/Web/Slack など任意)
$svc->notifyCompleted($this->date);
}
public function failed(\Throwable $e): void
{
// 失敗時の通知・補助情報
// 例:$svc->notifyFailed($this->date, $e->getMessage());
}
}
設計ポイント:
ShouldQueue
で非同期化。tries
とtimeout
を明示。middleware()
でレート制限やユニーク制御を付与可能。- 冪等性(exists チェック等)を最初に置き、二重実行でも安全に。
- 失敗時
failed()
で原因と対処を通知。
4. 重複実行と冪等性:事故を未然に防ぐ
4.1 スケジューラのロックとユニークジョブ
withoutOverlapping(minutes)
をスケジュールに付与。- キュー側はユニークキーで重複投入を防止(例えば Redis ロック)。
// 簡易ユニーク投入例(サービス層などで)
$lockKey = "job:build-sales:{$date}";
if (cache()->lock($lockKey, 600)->get()) {
BuildSalesReport::dispatch($date);
// 後続で、ジョブ開始時/終了時に release() も可能
}
4.2 冪等な設計
- 成果物(レポートファイル等)は決定的なパス/IDで保存し、存在すればスキップまたは上書き。
- 外部 API はページング・カーソルで再取得しても同じ結果になるように。
- DB 更新はトランザクション+アップサートで途中失敗に強く。
5. スケーリング:大きな処理を小さく分ける
5.1 チャンクと逐次処理
// 大量行の集計
Order::whereDate('created_at', $this->date)
->orderBy('id')
->chunk(5_000, function ($chunk) use ($writer) {
foreach ($chunk as $order) {
$writer->appendRow($order);
}
});
- メモリ使用量を抑え、長時間ジョブでも安定。
- 集計結果の出力はストリーム(ファイルへ順次書き込み)を基本に。
5.2 並列化と依存関係
- 大量ジョブを日単位/店舗単位/カテゴリ単位に分割してディスパッチ。
- 依存関係がある場合は親→子の順序やキュー名でレーン分離。
- レポート生成 → メール送信のように、キューを段階化すると監視しやすい。
6. 監視と可視化:失敗にすぐ気づき、静かに復旧
6.1 ジョブの状態監視
- 失敗ジョブ(failed_jobs テーブル)を毎朝集計して通知。
- ワーカーは Supervisor などで自動再起動とログ保全。
- キューの滞留(レイテンシ/件数)を指標化し、閾値でアラート。
6.2 スケジューラの健全性チェック
- 毎日 00:05 に「スケジューラは動いている」ハートビートを送信。
- 動いていない日はアラートで気付けるように。
schedule:list
/schedule:test
を定期的に実行し、設定ドリフトを防止。
7. メンテナンス時間帯とタイムゾーン
- 事業都合の静穏時間帯は「重い処理を避ける」。
skip(function() { ... })
で営業日/祝日等を条件分岐。- 取引先 API のレート制限窓口に合わせてスケジュールを設計。
- ユーザー向け通知は受け手のタイムゾーンを考慮。
$schedule->command('notify:daily')
->dailyAt('08:00')
->timezone('Asia/Tokyo')
->skip(function () {
return today('Asia/Tokyo')->isWeekend();
});
8. 典型ユースケースと実装サンプル
8.1 CSV インポート(夜間バッチ)
- S3 へアップロードされたファイルを検出→分割してキューへ
- 各ジョブで検証→DB 反映(バリデーションエラーは別テーブルへ蓄積)
- 完了後に集計レポートとエラーリストをメールで通知
// app/Jobs/ImportChunk.php
public function handle(ImportService $svc): void
{
$rows = $svc->read($this->path, $this->offset, $this->limit);
foreach ($rows as $row) {
$dto = $svc->validate($row); // 失敗は収集して後で通知
$svc->upsert($dto); // 冪等に
}
}
8.2 外部 API 集計(毎時)
- API レート制限に合わせてキューのスロットリング(
RateLimited
ミドルウェア) - 途中失敗は自動再試行、一定回数失敗で保留キューへ退避し運用判断
9. ユーザー向け進捗・結果画面のアクセシビリティ
9.1 進捗ダッシュボード(Web)
{{-- 進捗サマリはライブリージョンで読み上げ --}}
<div id="status" aria-live="polite" role="status" class="mb-3">
本日のレポート生成は 60% 完了(3/5 ステップ)
</div>
<ul class="space-y-2">
<li>
<span class="inline-flex items-center gap-2">
<span aria-hidden="true">⏳</span>
データ抽出
</span>
<span class="sr-only">進行中</span>
</li>
<li>
<span class="inline-flex items-center gap-2 text-green-700">
<span aria-hidden="true">✔</span>
集計
</span>
<span class="sr-only">完了</span>
</li>
<li>
<span class="inline-flex items-center gap-2 text-gray-700">
<span aria-hidden="true">…</span>
ファイル出力
</span>
</li>
</ul>
- 視覚の色分けに加え、アイコン+テキストで状態を多重表現。
- 進捗更新は
aria-live="polite"
で読み上げ通知。 - キーボードでも操作できるよう、リンク/ボタンで操作を実装。
9.2 完了・失敗メールの文面
- HTML とプレーンテキストの両方を用意。
- 件名は「日時+内容+結果」の順で短く。
- 失敗時は再試行の可否・問い合わせ先・エラー要点を明記。
- 画像や色に依存せず、本文で意味が完結する構成に。
10. エラーハンドリングとロールバック
- トランザクションで部分成功を避ける。
- 外部 API 連携はサーキットブレーカや指数バックオフを採用。
- 失敗の種類を分類(データ不正/一時障害/恒久障害)し、再試行方針を分ける。
- 連鎖ジョブは親の失敗時に子を中止し、通知を一本化。
11. セキュリティと権限
- バッチ専用の環境変数/資格情報を最小権限で発行。
- 署名付き URL や一時的な認証トークンで成果物の直リンク配布を安全に。
- ログに個人情報や機微情報を残さない(マスキング)。
12. Horizon/Supervisor 運用(概要)
- Redis キュー利用時は可視化とスロットリング設定が容易。
- ワーカー数は処理時間×到達レイテンシで見積もり、段階的に増減。
- ログにはジョブ ID/対象日/カテゴリなど検索キーを必ず記録。
13. 品質保証:テスト戦略
13.1 単体・機能テスト(同期実行)
public function test_report_command_dispatches_job(): void
{
Queue::fake();
$this->artisan('report:daily-sales --date=2025-08-31')
->assertExitCode(0);
Queue::assertPushed(\App\Jobs\BuildSalesReport::class, function ($job) {
return $job->date === '2025-08-31';
});
}
Queue::fake()
でジョブ投入を検証。- サービス層は純粋なメソッドにし、テーブル駆動テストで網羅。
13.2 E2E(小規模で本当の実行)
- 小さな入力データで実際にファイル生成→整合性を検証。
- 失敗系(API 403/429 やネットワーク障害)をモックして、再試行と通知を確認。
14. チェックリスト(配布用)
スケジュール設計
- [ ]
timezone()
を明示し、withoutOverlapping()
/onOneServer()
を付与 - [ ] 営業日・静穏時間帯の
skip()
条件 - [ ] 失敗時通知・ハートビート
ジョブ設計
- [ ]
ShouldQueue
/tries
/timeout
/RateLimited
等を設定 - [ ] 冪等性(存在チェック・決定的パス・アップサート)
- [ ] チャンク/ストリームでメモリ上限に配慮
- [ ] 失敗時の
failed()
で原因と次の手段を通知
スケール/分割
- [ ] 日/店舗/カテゴリ単位に分割ディスパッチ
- [ ] キュー名でレーン分離、依存順を制御
監視/運用
- [ ] 失敗ジョブ集計・滞留アラート
- [ ] Supervisor/Horizon でワーカー監視・自動再起動
- [ ] ログ検索キー(ジョブID/日付/カテゴリ)
アクセシビリティ
- [ ] 進捗/結果画面は
role="status"
/aria-live
で読み上げ - [ ] 成功/失敗を色以外(アイコン/文言)でも表現
- [ ] メールはプレーンテキスト版を必ず用意
セキュリティ
- [ ] 最小権限の資格情報
- [ ] 成果物配布は署名付き URL
- [ ] ログのマスキング
15. 具体的テンプレート(完成例・抜粋)
15.1 Console Kernel
protected function schedule(Schedule $schedule): void
{
// ハートビート
$schedule->call(fn() => \Log::info('scheduler:alive'))
->dailyAt('00:05')
->timezone('Asia/Tokyo')
->onOneServer();
// 日次レポート
$schedule->command('report:daily-sales')
->dailyAt('03:00')
->timezone('Asia/Tokyo')
->withoutOverlapping(60)
->onOneServer()
->emailOutputOnFailure('ops@example.com');
}
15.2 成果物の署名付きダウンロード
// routes/web.php
Route::get('/reports/{date}', [ReportController::class, 'show'])
->middleware('signed')
->name('reports.show');
// 生成
$url = URL::temporarySignedRoute('reports.show', now()->addHours(24), ['date' => $date]);
15.3 進捗更新のポーリング(簡易)
async function poll() {
const res = await fetch('/api/report/progress?date=2025-08-31', {headers:{Accept:'application/json'}});
if (res.ok) {
const { percent, stepText } = await res.json();
const el = document.getElementById('status');
el.textContent = `進捗 ${percent}%:${stepText}`;
}
setTimeout(poll, 15000);
}
poll();
16. よくある落とし穴と回避策
- 同期的に重い処理を
command
に直書き → 必ずジョブへ切り出し、再試行・監視を得る。 - 何度走っても結果が変わる → 冪等性(存在チェック、決定的キー、アップサート)。
- 「完了メールのみ」→失敗時に気づけない。失敗通知とハートビートを必ず。
- 色だけで進捗表示 → 状態が判別しにくい。アイコン+文言を併用。
- タイムゾーン未指定 → 想定外の時刻に実行。
timezone()
を明示。 - 大きな CSV を一括読み → メモリ圧迫。チャンク処理とストリーム出力。
17. チーム運用に馴染ませるコツ
- コマンド・ジョブ・サービス層を定型化し、ひな形から追加できるようにする。
- 命名規約(
Build*
,Import*
,Notify*
)で一覧性を上げる。 - 成果物の出力先・命名(
reports/YYYYMM/DD/sales_{date}.csv
)を統一。 - 失敗通知は運用窓口を一本化し、個人宛メールにしない。
- ダッシュボードに日別の完了/失敗数を集計し、異常傾向を早期発見。
18. まとめ:小さく確実に回し、静かにスケールする
- スケジューラで時間管理をコード化し、
withoutOverlapping()
とonOneServer()
で安全に。 - 仕事はジョブに任せ、再試行・レート制限・冪等性で壊れにくく。
- 大量処理はチャンクと並列化でスループットを稼ぎ、指標で滞留を可視化。
- 結果の提示はアクセシビリティを最初から考慮し、読み上げ・色以外の表現・テキスト版メールで誰でも理解できる形に。
- 失敗は悪ではなく信号。失敗通知とハートビートで早く気づき、静かに復旧できる体制を。
本記事のテンプレートを土台に、あなたのチームでも「静かで、確実で、やさしい」定期処理を育ててください。わたしも応援しています。