LaravelのArtisanコマンドを本番運用する:冪等性・二重実行防止・再実行の設計
作成日:2026.09.22
LaravelのArtisanコマンドでデータベースを更新する処理を、本番環境で安全に運用するための考え方を整理します。商品在庫の一括更新を例に、冪等性、dry-run、チャンク処理、トランザクション、二重実行防止、失敗後の再実行、ログや終了コード、実行前後の確認手順を紹介します。
目次
LaravelのArtisanコマンドは、データの集計や同期、メンテナンス処理などを、Web画面とは別に実行するための入口として利用できます。
以前、Laravel 13でArtisanコマンドを作成する方法として、コマンドの作成、引数やオプション、dry-run、終了コードなどを紹介しました。
今回はその続きとして、データベースを更新するArtisanコマンドを本番環境で運用するときに確認したい点を整理します。商品在庫データの一括更新を例として、冪等性、二重実行防止、チャンク処理、途中失敗後の再実行、スケジューラーの設定を確認します。
※この記事の例は特定のプロジェクトの実装や運用結果ではありません。実際のアプリケーションへ導入する場合は、データ量、利用するデータベース、更新処理の性質に合わせて検証してください。
前提
記事中の例は、Laravel 13.x、PHP 8.3系を前提にしています。データベースはLaravelから利用できる一般的なリレーショナルデータベースを想定します。
LaravelのArtisanコマンド自体の作成方法や、属性によるコマンド定義については、Laravel 13.xのArtisan Console公式ドキュメントも参照してください。
本番運用で考えること
開発環境でコマンドが最後まで実行できても、本番環境で安全に繰り返し実行できるとは限りません。DBを更新するコマンドでは、少なくとも次の点を確認します。
- どのレコードを更新するのか
- 同じコマンドを再実行したときに、二重更新にならないか
- 同じ処理が複数起動したときに、対象が競合しないか
- 大量データを処理してもメモリや実行時間に問題がないか
- 途中で失敗した場合に、どこまで処理済みか確認できるか
- 失敗後に同じコマンドを安全に再実行できるか
- 実行先の環境、ログ、終了コードを確認できるか
dry-runを用意することは重要ですが、それだけで十分とは限りません。dry-runと本実行の間に別の処理が動くこともあるため、対象条件、トランザクション、排他制御、再実行方法を組み合わせて考えます。
商品在庫の一括更新を例にする
今回は、棚卸しなどで用意した商品在庫の更新データを、商品テーブルへ反映するコマンドを作ります。
説明用に、次の2つのテーブルがあるとします。
| テーブル | 主なカラム | 役割 |
|---|---|---|
products |
id、name、stock |
現在の商品在庫を保持する |
inventory_updates |
id、product_id、stock、status、processed_at |
反映待ちの在庫更新データを保持する |
inventory_updates.statusは、次のように状態を管理します。
| 状態 | 意味 |
|---|---|
pending |
まだ商品へ反映していない |
applied |
商品へ反映済み |
今回の処理では、在庫を「現在の在庫に10個加算する」のではなく、「更新データに入っている在庫数へ置き換える」ことにします。
例えば、更新データの在庫数が50であれば、何らかの理由で同じ更新を再実行しても、在庫は50になります。加算処理のように再実行するたびに値が増える設計と比べて、再実行時の影響を抑えやすくなります。
対象条件を先に決める
まず、処理対象を決める条件を整理します。今回の対象は、statusがpendingの更新データだけです。
$targetQuery = DB::table('inventory_updates')
->where('status', 'pending');
この条件をdry-runと本実行で共有します。dry-runだけ別の条件で検索すると、確認したレコードと実際に更新するレコードがずれる可能性があります。
更新済みのデータを対象から外す条件を持たせることで、処理が正常に完了した後に同じコマンドを再実行しても、同じ更新データを何度も処理しません。
冪等性を意識した更新にする
同じ処理を複数回実行しても、結果が意図せず変わらない性質を冪等性と呼びます。
今回の例では、次の2点で冪等性を考えています。
pendingの更新データだけを対象にする- 在庫を相対的に加算せず、更新データの値へ置き換える
ただし、冪等性はコマンドへ特別なオプションを追加するだけで得られるものではありません。何を処理済みとみなすのか、途中で失敗した場合にどの状態を残すのかを、データ構造と更新条件で決める必要があります。
dry-runで対象を確認する
本実行の前に、更新対象と更新後の在庫数を表示できるようにします。
$targets = (clone $targetQuery)
->orderBy('id')
->limit($limit)
->get(['id', 'product_id', 'stock']);
if ($targets->isEmpty()) {
$this->info('反映対象の在庫データはありません。');
return self::SUCCESS;
}
$this->table(
['更新データID', '商品ID', '更新後在庫'],
$targets->map(static fn (object $target): array => [
$target->id,
$target->product_id,
$target->stock,
])->all(),
);
$this->comment('dry-runのため、データベースは更新していません。');
dry-runはデータベースを更新せず、対象の確認だけを行います。画面へ商品名や在庫数を表示する場合も、個人情報や内部管理情報を必要以上に出力しないようにします。
また、dry-runの結果を確認した直後に本実行する場合でも、別の利用者や処理が対象データを変更する可能性があります。重要な処理では、更新直前に対象条件をもう一度確認する設計も検討します。
コマンドの入力値を制限する
一度の実行で処理する件数と、1回のトランザクションで扱う件数をオプションにします。
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
#[Signature(
'inventory:apply
{--chunk=100 : 1回のトランザクションで処理する件数}
{--limit=500 : 1回の実行で処理する最大件数}
{--dry-run : 対象を表示するだけで更新しない}'
)]
#[Description('商品在庫の更新データを反映する')]
--limitは、本番データを一度に大量更新しないための上限です。例えば、更新待ちのデータが5,000件あっても、1回の実行では500件だけ処理し、結果を確認してから次の実行へ進む方法を取れます。
上限値は一般的な推奨値ではありません。データベースの性能、1件あたりの処理内容、ロックの時間、許容できる実行時間を確認して決めます。
チャンク単位で更新する
大量のレコードをすべてget()で読み込むと、メモリ使用量が増える可能性があります。更新しながら処理する場合は、主キーを基準に分割するchunkByIdを検討します。
Laravelの公式ドキュメントでも、取得したレコードを更新しながらチャンク処理する場合は、通常のchunkよりchunkByIdを使う方法が紹介されています。チャンク処理が独自にwhere条件を追加するため、複数の条件を使うときは論理的なグループ化にも注意が必要です。
$processed = 0;
$targetQuery->chunkById(
$chunkSize,
function (Collection $updates) use (&$processed, $limit): bool {
$currentUpdates = $updates->take($limit - $processed);
DB::transaction(function () use ($currentUpdates): void {
foreach ($currentUpdates as $candidate) {
// 実行時点の状態をロック付きで再確認する
$update = DB::table('inventory_updates')
->where('id', $candidate->id)
->where('status', 'pending')
->lockForUpdate()
->first();
if ($update === null) {
throw new RuntimeException(
"更新データ {$candidate->id} は処理対象ではありません。"
);
}
$productExists = DB::table('products')
->where('id', $update->product_id)
->exists();
if (! $productExists) {
throw new RuntimeException(
"商品 {$update->product_id} が見つかりません。"
);
}
DB::table('products')
->where('id', $update->product_id)
->update([
'stock' => $update->stock,
'updated_at' => now(),
]);
DB::table('inventory_updates')
->where('id', $update->id)
->where('status', 'pending')
->update([
'status' => 'applied',
'processed_at' => now(),
'updated_at' => now(),
]);
}
});
$processed += $currentUpdates->count();
return $processed < $limit;
},
'id',
);
この例では、チャンクごとにトランザクションを開始しています。商品更新と更新データのappliedへの変更が同じトランザクションに含まれるため、途中で例外が発生したチャンクはまとめてロールバックされます。
例えば、3件目の商品が存在しなかった場合、そのチャンク内で先に処理した1件目と2件目の更新も確定しません。原因を修正してから再実行すれば、同じチャンクを改めて処理できます。
lockForUpdate()は、トランザクション内で対象行をロックしてから状態を確認するために使っています。利用するデータベースによってロックの挙動が異なるため、本番環境と同じデータベースで確認してください。
LaravelのDB::transaction()は、処理が最後まで成功したときだけ、その中で行った変更をデータベースへ確定します。途中でエラーが起きた場合は、その処理の中で行った変更をすべて取り消します。トランザクションの範囲を広げすぎるとロック保持時間も長くなるため、全件を一つのトランザクションにするのではなく、処理単位に分ける方法も検討しましょう。
コマンド全体の実装例
ここまでの処理を組み合わせると、コマンド全体は次のようになります。実際のプロジェクトでは、商品更新処理をActionやサービスクラスへ分けても構いません。
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
#[Signature(
'inventory:apply
{--chunk=100 : 1回のトランザクションで処理する件数}
{--limit=500 : 1回の実行で処理する最大件数}
{--dry-run : 対象を表示するだけで更新しない}'
)]
#[Description('商品在庫の更新データを反映する')]
class ApplyInventory extends Command
{
public function handle(): int
{
$chunkSize = filter_var((string) $this->option('chunk'), FILTER_VALIDATE_INT, [
'options' => [
'min_range' => 1,
'max_range' => 1000,
],
]);
$limit = filter_var((string) $this->option('limit'), FILTER_VALIDATE_INT, [
'options' => [
'min_range' => 1,
'max_range' => 10000,
],
]);
if ($chunkSize === false || $limit === false) {
$this->error('chunkとlimitには正の整数を指定してください。');
return self::FAILURE;
}
$targetQuery = DB::table('inventory_updates')
->where('status', 'pending');
if ($this->option('dry-run')) {
$targets = (clone $targetQuery)
->orderBy('id')
->limit($limit)
->get(['id', 'product_id', 'stock']);
if ($targets->isEmpty()) {
$this->info('反映対象の在庫データはありません。');
return self::SUCCESS;
}
$this->info("反映対象: {$targets->count()}件");
$this->table(
['更新データID', '商品ID', '更新後在庫'],
$targets->map(static fn (object $target): array => [
$target->id,
$target->product_id,
$target->stock,
])->all(),
);
$this->comment('dry-runのため、データベースは更新していません。');
return self::SUCCESS;
}
$processed = 0;
try {
$targetQuery->chunkById(
$chunkSize,
function (Collection $updates) use (&$processed, $limit): bool {
$currentUpdates = $updates->take($limit - $processed);
DB::transaction(function () use ($currentUpdates): void {
foreach ($currentUpdates as $candidate) {
$update = DB::table('inventory_updates')
->where('id', $candidate->id)
->where('status', 'pending')
->lockForUpdate()
->first();
if ($update === null) {
throw new RuntimeException(
"更新データ {$candidate->id} は処理対象ではありません。"
);
}
if (! DB::table('products')
->where('id', $update->product_id)
->exists()) {
throw new RuntimeException(
"商品 {$update->product_id} が見つかりません。"
);
}
DB::table('products')
->where('id', $update->product_id)
->update([
'stock' => $update->stock,
'updated_at' => now(),
]);
DB::table('inventory_updates')
->where('id', $update->id)
->where('status', 'pending')
->update([
'status' => 'applied',
'processed_at' => now(),
'updated_at' => now(),
]);
}
});
$processed += $currentUpdates->count();
$this->info("{$processed}件を処理しました。");
return $processed < $limit;
},
'id',
);
} catch (\Throwable $e) {
Log::error('商品在庫の一括更新に失敗しました。', [
'processed' => $processed,
'exception' => $e,
]);
$this->error('処理に失敗しました。ログを確認してください。');
return self::FAILURE;
}
$this->info("合計 {$processed}件を処理しました。");
return self::SUCCESS;
}
}
この例では、エラーが発生した場合に例外をログへ記録し、self::FAILUREを返しています。定期実行の仕組みや監視側で終了コードを利用する場合は、対象なしを成功とみなすかどうかも決めておきます。
また、--limitはチャンクの取得単位を超えて処理しないための上限です。上限まで処理したらチャンク処理を終了し、次回の実行ではまだpendingのデータを対象にします。
実行結果を確認する
まずはdry-runで、対象を確認します。
php artisan inventory:apply --limit=20 --dry-run
問題がなければ、処理件数とチャンクサイズを指定して本実行します。
php artisan inventory:apply --chunk=100 --limit=500
処理後は、次の点を確認します。
inventory_updates.statusが対象件数だけappliedになっているproducts.stockが更新データの値になっているprocessed_atが記録されている- 対象外の更新データが変更されていない
- コマンドの終了コードが成功を示している
- ログに処理件数や失敗内容が記録されている
もう一度同じコマンドを実行して、すでにappliedになった更新データが再び対象にならないことも確認します。
途中失敗後に再実行する
大量データの処理では、ネットワーク障害、データ不整合、データベースのデッドロック、プロセス停止などが起こる可能性があります。
今回の例では、チャンク単位でトランザクションを確定します。そのため、次のような状態になります。
| 状況 | 結果 |
|---|---|
| チャンクの全処理が成功 | 商品と更新データの変更が確定される |
| チャンク内で例外が発生 | そのチャンクの変更がロールバックされる |
| チャンクの確定後にプロセスが停止 | 確定済みのチャンクは残り、未処理分がpendingのまま残る |
再実行する前に、ログとデータベースを確認します。エラー原因が存在しない商品なら、商品データを修正してから再実行します。入力データそのものが誤っている場合は、対象を修正または除外する手順が必要です。
「失敗したら同じコマンドを実行すればよい」と決めるのではなく、次の確認を行ってから再実行します。
- どのチャンクまで確定されたか確認する
- 失敗した更新データの状態と内容を確認する
- 商品側の在庫が意図しない状態になっていないか確認する
- 原因を修正し、必要ならバックアップや復旧手順を確認する
- dry-runで再実行対象を確認する
二重実行を防止する
Schedulerで定期的に実行する処理が前回の実行時間を超えると、設定によっては次のプロセスが起動します。Laravelの公式ドキュメントでは、スケジュールされた処理の重複を防ぐためにwithoutOverlappingを利用できます。
use Illuminate\Support\Facades\Schedule;
Schedule::command('inventory:apply --chunk=100 --limit=500')
->everyFiveMinutes()
->withoutOverlapping(30);
withoutOverlapping(30)の引数は、重複防止用のロックが残った場合に、何分後に期限切れとするかを指定する例です。処理時間や異常終了の可能性を考慮して決めます。
複数のサーバーでSchedulerを動かしている場合は、各サーバーが同じスケジュールを実行する可能性があります。この場合はonOneServerを使う方法があります。
Schedule::command('inventory:apply --chunk=100 --limit=500')
->everyFiveMinutes()
->withoutOverlapping(30)
->onOneServer();
onOneServerやwithoutOverlappingのロックを複数サーバーで共有するには、共有キャッシュなどの構成が必要です。各サーバーが別々のファイルキャッシュを使っているだけでは、同じ処理を1台だけに制限できない場合があります。
Isolatable Commandsを使う方法
Isolatableは、同じArtisanコマンドを同時に複数実行しないための仕組みです。Schedulerだけでなく、運用担当者が手動で同じコマンドを実行する可能性がある場合などに利用できます。
use Illuminate\Contracts\Console\Isolatable;
class ApplyInventory extends Command implements Isolatable
{
// handleメソッドなどを定義する
}
Isolatableを実装すると、コマンドに--isolatedオプションが自動的に追加されます。
php artisan inventory:apply --isolated=12
別のプロセスが実行中でロックを取得できない場合の終了コードを指定したいときは、--isolated=12のように値を渡します。監視側で「処理が失敗した」のか「別の処理が実行中だった」のかを区別したい場合に利用できます。
ただし、ロック機能を使っても、データ更新の条件やトランザクション設計が不要になるわけではありません。ロックの期限切れ、手動実行、別の処理経路からの更新なども考慮し、データ側でも再実行に耐えられる条件を持たせます。
Scheduler、cron、キューを使い分ける
定期的な在庫更新では、Scheduler、cron、キューを組み合わせることがあります。それぞれの役割を分けて考えます。
| 仕組み | 向いている用途 | 確認する点 |
|---|---|---|
| cron | 一定間隔でSchedulerを起動する | 実行ユーザー、作業ディレクトリ、PHPのパス、ログ |
| Scheduler | アプリケーション内で実行時刻や重複防止を管理する | ロック用キャッシュ、複数サーバー、タイムゾーン |
| キュー | 処理を小さなJobへ分割し、再試行や並列処理を行う | 重複実行、再試行、Jobの冪等性、失敗Jobの確認 |
例えば、cronでは毎分schedule:runだけを起動し、実際の実行間隔やコマンドはSchedulerへ集約する方法があります。
* * * * * cd /var/www/example.com/current && /usr/bin/php artisan schedule:run >> /dev/null 2>&1
1件ごとの処理に時間がかかる場合は、更新データをJobへ分割してキューへ投入する設計も考えられます。ただし、キューは失敗時に再試行されるため、Job側でも同じデータを複数回処理して問題が起きない設計が必要です。
終了コードとログを設計する
Artisanコマンドの実行結果は、画面に表示するメッセージだけでなく、終了コードでも表現します。
| 状態 | 扱いの例 |
|---|---|
| 処理成功 | self::SUCCESSを返す |
| 対象なし | 要件に応じて成功または専用コードとする |
| 入力値エラー | self::FAILUREなどの失敗コードを返す |
| 途中失敗・例外 | トランザクションをロールバックし、ログを残して失敗コードを返す |
ログには、少なくとも次のような情報を残すと、再実行の判断をしやすくなります。
- コマンド名と指定したオプション
- 実行開始時刻と終了時刻
- 対象件数、処理件数、スキップ件数、失敗件数
- 最後に処理したIDやチャンクの範囲
- 例外の種類と、原因を調査するための識別情報
商品名や利用者情報などをログへ出力する場合は、本当に必要か確認します。エラーの原因調査に必要なIDだけを記録し、個人情報や認証情報をログへ残さないようにします。
本番実行前後の確認手順
DB更新コマンドを本番で実行する前に、次の項目を確認します。
- 実行しているサーバーとアプリケーションのリリースを確認する
- 接続先の環境が本番であることを確認する
- 対象データのバックアップや復旧手段を確認する
- dry-runで対象件数と代表的な対象を確認する
- 必要なら処理上限を小さくして最初の実行を行う
- 終了コード、ログ、更新後の件数を確認する
- 失敗した場合は、原因と処理済み範囲を確認してから再実行する
Laravel 13.xでは、db:showなどのArtisanコマンドで、アプリケーションが接続するデータベースの情報を確認できます。
php artisan db:show --counts
大きなデータベースでは件数取得に時間がかかる場合があるため、実行する環境やタイミングに注意します。接続先を確認するために、アプリケーションの設定や環境変数もあわせて確認してください。
バックアップやロールバック方法は、Artisanコマンドだけで用意できるものではありません。データベースのバックアップ方式、復元にかかる時間、対象データだけを戻せるかどうかを、運用環境の手順として別に確認します。
Console Testで安全性を確認する
手動実行だけでなく、dry-runでDBが変更されないこと、通常実行で対象だけが変更されること、再実行で処理済みデータが対象にならないことをテストします。
public function test_dry_run_does_not_apply_inventory(): void
{
$this->artisan('inventory:apply --dry-run')
->assertExitCode(0);
$this->assertDatabaseHas('inventory_updates', [
'status' => 'pending',
]);
}
public function test_it_does_not_process_applied_updates_again(): void
{
$this->artisan('inventory:apply')
->assertExitCode(0);
$this->artisan('inventory:apply')
->assertExitCode(0);
$this->assertDatabaseCount('inventory_updates', 1);
}
実際には、テストデータを用意して、商品在庫が更新されること、更新データがappliedになること、存在しない商品でチャンク全体がロールバックされることも確認します。
並列実行やプロセス停止後の再実行は、通常の単体テストだけでは確認しにくい場合があります。検証用の環境で2つのプロセスを起動したり、処理途中でコマンドを停止したりして、実際のデータベースの挙動を確認します。
注意点
- dry-runは、本実行との間に発生するデータ変更や、二重実行を自動で防ぐ仕組みではありません。
withoutOverlappingや--isolatedを使っても、データ更新側の冪等性は別に設計します。chunkByIdでは、チャンク処理中に主キーや検索条件へ影響するカラムを変更しないようにします。- トランザクションを大きくしすぎると、ロック保持時間やロールバック時の負荷が増える場合があります。
- 対象件数の上限を設けても、1件あたりの処理が重ければ実行時間は長くなります。
- 実行先のDBを間違えると、本番データや別環境のデータを意図せず更新する可能性があります。
- ログやコンソールへ、個人情報、認証情報、在庫データの詳細を必要以上に出力しないようにします。
- 再試行されるキューや手動再実行を含め、同じデータが複数回処理される前提で設計します。
まとめ
DBを更新するArtisanコマンドを本番運用するときは、処理を実装して実行できるだけでなく、再実行や途中失敗まで考えて設計する必要があります。
- 更新済みのデータを対象から外し、状態遷移で冪等性を作る
- dry-runと対象件数の上限で、実行前に対象を確認する
chunkByIdとチャンク単位のトランザクションで、大量データを分割するwithoutOverlapping、onOneServer、Isolatable Commandsを実行経路に応じて使い分ける- 終了コード、ログ、バックアップ、再実行手順を用意する
本番運用向けの安全対策に、すべての処理へ共通する一つの正解があるわけではありません。更新するデータの性質、件数、実行頻度、複数サーバーの有無を確認し、必要な対策を組み合わせるとよいかと思います。
参考資料
奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。
これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。
【制作会社・企業様向けサポート】
Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。
ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。
Laravel 13のNamed Rate Limiterでレート制限を実装する:IP・ユーザー・入力値ごとの設定
2026.09.19
LaravelのNamed Rate Limiterを使い、無制限に繰り返されたくない処理へレート制限を追加する方法を解説します。RateLimiter::for()とthrottleミドルウェアの基本から、IPアドレス・ユーザーID・入力値による制限、複数の制限、429レスポンス、ログイン処理への応用、Redis利用時の注意点まで整理します。
Laravel SocialiteでGoogleログインを実装する|OAuth設定から初回登録まで
2026.09.18
Laravel Socialiteを使ったGoogleログインを、OAuthクライアントの設定からコールバック、Laravelユーザーの登録・ログインまで解説します。Googleアカウントは変更されるメールアドレスではなくsubで識別し、初回登録時は検証済みメールアドレスを確認します。既存アカウントへ自動連携しない設計や、セッション管理、Socialite Fakeで確認するテスト項目も紹介します。
Laravel Pintの使い方:PHPコードの書式を整えてCIで検査する
2026.09.15
Laravel Pintを使ってPHPコードの書式を整える方法を紹介します。ローカルでの自動修正、CIで書式違反だけを検査する--test、対象範囲の指定、pint.jsonの設定、Bladeファイルを扱う際の注意点を整理し、Pintを導入する判断基準も説明します。
GitHub ActionsでPHP・LaravelのCIを始める:テスト・Lint・ビルドを自動化するWorkflowの基本
2026.09.13
PHP・Laravelプロジェクトを対象に、GitHub ActionsのWorkflow、イベント、ジョブ、ステップの基本を説明します。Composerのテスト、npm ci、フロントエンドビルド、Laravel PintをCIへ組み込む方法に加え、featureブランチやPull Requestで実行される条件、失敗時のログ確認、Secrets・権限・Actionのバージョン管理についても整理します。