MENU

問い合わせ


    【Laravel+Vue.jsで作る管理画面をECSで動かす 第5回】請求と非同期処理:キューとスケジューラをローカルで動かす

    管理画面の「請求書発行」ボタンを押したあと、画面が数秒固まる。月末の締め処理は担当者が手作業で回している。業務システムではよくある光景です。今回は、案件にひもづく「請求」を追加し、請求書の発行を Laravel 12 のキュー(=あとで順番に処理する仕事の待ち行列)で非同期に実行し、月次締めと支払期限の超過チェックをスケジューラ(=決まった時刻にコマンドを実行する仕組み)で定期実行します。

    「請求書の発行ボタンを押すと、画面がしばらく止まる。裏で処理させればいいと聞いたけれど、失敗したときにどうなるのかが心配…」

    結論から言うと、非同期処理は「受け付け」と「実行」を分け、状態(下書き→処理中→発行済み)をデータベースに持たせ、同じ処理が2回動いても結果が変わらないように作るのが基本です。こうしておけば、ボタンの二度押しや再試行があっても、請求書の二重発行を仕組みで防げます。

    前回(第4回:案件管理と一覧画面の作り込み)では、案件一覧に検索・並べ替え・ページングを付けました。今回はその案件に請求を追加し、Docker の開発環境にキューワーカーとスケジューラのコンテナを加えます。

    目次

    請求の非同期処理とスケジューラで作るもの

    今回の実装は次の4つです。

    機能仕組みいつ動くか
    請求書の発行IssueInvoice ジョブ(キュー)画面の「発行」ボタン
    月次締めinvoices:close-month コマンド毎月1日 6:00(前月分の下書きをまとめて発行キューへ)
    支払期限の超過チェックinvoices:mark-overdue コマンド毎日 1:00
    発行状況の表示Inertia 2 のポーリング「発行処理中」の請求があるあいだ2秒ごと

    キューの保存先は、ローカルでは Laravel 12 の既定である database ドライバ(jobs テーブル)を使います。本番の ECS では Amazon SQS に切り替える予定です(第10回)。アプリのコードは変えずに、QUEUE_CONNECTION の設定だけで切り替えられるのが Laravel のキューのよいところです。

    請求の状態は次のように移ります。

    • 下書き → 発行処理中(ボタンを押した、または月次締めでキューに積まれた)→ 発行済み(ワーカーが処理した)
    • 発行済み → 期限超過(支払期限を過ぎても入金がない。毎日のチェックで変わる)
    • 発行処理中 → 下書き(再試行しても失敗した。画面からもう一度発行できる)

    なお、請求の登録・編集画面は第2回の顧客と同じ作りになるため、この連載では省略し、下書きの請求はシーダーで作ります。

    invoices テーブルとステータスの enum

    マイグレーション:同じ案件・同じ月の請求は1件だけ

    database/migrations/2025_07_08_000000_create_invoices_table.php(up 部分)

            Schema::create('invoices', function (Blueprint $table): void {
                $table->id();
                // 請求は必ず1つの案件に属する。請求が残っている案件は削除できない(第4回の方針を踏襲)
                $table->foreignId('project_id')->constrained()->restrictOnDelete();
                $table->string('number', 30)->nullable()->unique();  // 請求番号(発行時に採番)
                $table->date('billing_month');                         // 請求対象月(月初日で保存)
                $table->unsignedBigInteger('amount');                  // 請求金額(円)
                $table->string('status', 20)->default('draft')->index();
                $table->date('issue_date')->nullable();                // 発行日
                $table->date('due_date')->nullable()->index();         // 支払期限
                $table->timestamp('issued_at')->nullable();
                $table->timestamps();
    
                // 同じ案件・同じ月の請求は1件だけ
                $table->unique(['project_id', 'billing_month']);
            });

    請求番号は発行時に決まるので、下書きの間は空です。unique(['project_id', 'billing_month']) は、プログラムに不具合があっても同じ月の請求が二重に入らないようにする最後の防波堤です。

    ステータスは、第3回・第4回と同じく PHP の enum(app/Enums/InvoiceStatus.php)で定義しました。モデル(app/Models/Invoice.php)では、status・number・発行日を $fillable に入れていません。これらは画面から直接書き換えさせず、発行ジョブと定期実行のコマンドだけが変更する、という役割分担をコードで表しています。

    請求書の発行をジョブにする

    受け付け:1回の UPDATE で「下書きなら処理中にする」

    app/Models/Invoice.php(発行の受け付け)

        /**
         * 発行を受け付けてキューに積む。下書き以外(処理中・発行済み)なら何もせず false を返す
         */
        public function queueIssue(): bool
        {
            // 「下書きなら処理中にする」を1つの UPDATE で行う。
            // ボタンの二度押しや締め処理と重なっても、ジョブを積めるのは最初の1回だけになる
            $updated = static::query()
                ->whereKey($this->id)
                ->where('status', InvoiceStatus::Draft)
                ->update(['status' => InvoiceStatus::Issuing]);
    
            if ($updated === 0) {
                return false;
            }
    
            $this->status = InvoiceStatus::Issuing;
            IssueInvoice::dispatch($this);
    
            return true;
        }

    「状態を読む」と「書き換える」を2回に分けると、ほぼ同時の2つのリクエストがどちらも「下書き」を読み、ジョブが2つ積まれます。WHERE status = 'draft' 付きの1回の UPDATE なら、更新できるのは最初の1回だけで、更新件数0は「処理中か発行済み」を意味します。

    コントローラーは、権限を確認してこのメソッドを呼び、すぐに画面を返します。

    app/Http/Controllers/InvoiceController.php(発行)

        /**
         * 請求書の発行:その場では処理せず、キューに積んですぐ画面を返す
         */
        public function issue(Invoice $invoice): RedirectResponse
        {
            Gate::authorize('issue', $invoice);
    
            if (! $invoice->queueIssue()) {
                return back()->with('error', 'この請求はすでに発行済みか、発行処理中です。');
            }
    
            return back()->with('success', '発行を受け付けました。処理が終わると一覧の状態が「発行済み」に変わります。');
        }

    発行できるのは担当者と管理者で、閲覧のみのユーザーは 403 になります(app/Policies/InvoicePolicy.php。管理者は第3回の Gate::before で許可)。

    実行:IssueInvoice ジョブ

    app/Jobs/IssueInvoice.php(抜粋)

    class IssueInvoice implements ShouldQueue
    {
        use Queueable;
    
        /** 失敗したときの最大試行回数(1回目+再試行2回) */
        public int $tries = 3;
    
        /** 1回の実行の制限時間(秒)。retry_after(既定 90 秒)より短くする */
        public int $timeout = 60;
    
        public function __construct(public Invoice $invoice)
        {
            // モデルはIDだけがキューに保存され、実行時にDBから読み直される
        }
    
        /**
         * 再試行までの待ち時間(秒)。1回目の失敗後 10 秒、2回目の失敗後 60 秒
         *
         * @return list<int>
         */
        public function backoff(): array
        {
            return [10, 60];
        }
    
        public function handle(): void
        {
            DB::transaction(function (): void {
                // 同じ請求を2つのワーカーが同時に処理しないよう、行をロックして読み直す
                $invoice = Invoice::query()->lockForUpdate()->findOrFail($this->invoice->id);
    
                // 「発行処理中」以外なら何もしない(二重実行・再試行で同じ処理を繰り返さない)
                if ($invoice->status !== InvoiceStatus::Issuing) {
                    return;
                }
    
                $today = today(config('app.schedule_timezone'));
    
                $invoice->forceFill([
                    // 請求番号:発行年月+ID(ID は重複しないので採番の競合が起きない)
                    'number' => sprintf('INV-%s-%06d', $today->format('Ym'), $invoice->id),
                    'issue_date' => $today->toDateString(),
                    // 支払期限:発行月の翌月末
                    'due_date' => $today->copy()->addMonthNoOverflow()->endOfMonth()->toDateString(),
                    'status' => InvoiceStatus::Issued,
                    'issued_at' => now(),
                ])->save();
    
                Log::info('請求書を発行しました', ['invoice_id' => $invoice->id, 'number' => $invoice->number]);
            });
        }
    
        /**
         * すべての試行が失敗したとき(failed_jobs テーブルにも記録される)
         */
        public function failed(?Throwable $exception): void
        {
            // 画面から再度「発行」できるよう下書きに戻す
            Invoice::query()
                ->whereKey($this->invoice->id)
                ->where('status', InvoiceStatus::Issuing)
                ->update(['status' => InvoiceStatus::Draft]);
    
            // (ログ出力は省略)
        }
    }

    ポイントは3つです。

    • 何度動いても結果が同じ(冪等=べきとう)にする:キューは「少なくとも1回は実行する」仕組みで、ワーカーの停止やタイムアウトの後に同じジョブがもう一度動くことがあります。先頭で状態を確認し、「発行処理中」でなければ何もしないようにしています。
    • 再試行の回数と間隔を決める:$tries と backoff() で、失敗したら10秒後、さらに60秒後にやり直します。一時的なネットワークエラーなどはこれで救えます。
    • 最後まで失敗したときの行き先を決める:failed() で下書きに戻し、画面から再発行できるようにしました。失敗したジョブは failed_jobs テーブルにも残り、php artisan queue:failed で確認できます。

    $timeout は、キューの設定 retry_after(database ドライバの既定は90秒)より短くします。逆にすると、まだ処理中のジョブを「止まった」と判断して別のワーカーがもう一度実行してしまいます。

    📰 出典:Laravel 12.x Queues(Max Job Attempts and Timeout Values)

    実際の業務では、このジョブの中で請求書 PDF の生成やメール送信を行います。この連載では、採番と状態の更新までにとどめています。PDF を作る場合、ECS(Fargate)のコンテナ内のファイルは停止すると消えるため、保存先は S3 などの外部ストレージにする必要があります。

    月次締めと期限超過チェックをスケジューラで定期実行する

    期限超過チェック:まとめて1回の UPDATE

    app/Console/Commands/MarkOverdueInvoices.php(handle 部分)

        public function handle(): int
        {
            // 「今日」は日本時間で判定する(サーバーの時刻は UTC)
            $today = today(config('app.schedule_timezone'))->toDateString();
    
            // 1件ずつ読み込まず、条件に合う行を1回の UPDATE でまとめて更新する
            $count = Invoice::query()
                ->where('status', InvoiceStatus::Issued)
                ->where('due_date', '<', $today)
                ->update(['status' => InvoiceStatus::Overdue]);
    
            Log::info('期限超過チェック', ['date' => $today, 'overdue' => $count]);
            $this->info("期限超過にした請求:{$count} 件");
    
            return self::SUCCESS;
        }

    月次締めのコマンド(app/Console/Commands/CloseMonthlyInvoices.php)は、対象月(既定は前月、--month=2025-06 のように指定も可)の下書きを100件ずつ読み込み、先ほどの queueIssue() でキューに積むだけです。発行そのものはワーカーが順番に処理するので、コマンドはすぐに終わります。

    スケジュールの定義:routes/console.php

    routes/console.php(追加部分)

    // 毎日 1:00:支払期限を過ぎた請求を「期限超過」にする
    Schedule::command('invoices:mark-overdue')
        ->dailyAt('01:00')
        ->onOneServer()
        ->withoutOverlapping();
    
    // 毎月1日 6:00:前月分の下書きの請求を発行キューに積む(月次締め)
    Schedule::command('invoices:close-month')
        ->monthlyOn(1, '06:00')
        ->onOneServer()
        ->withoutOverlapping();
    • onOneServer():スケジューラが複数台で動いていても、1台だけが実行します。キャッシュのロック(database・redis などのドライバが必要。Laravel 12 の既定は database)を使います。本番ではスケジューラを1台に固定する予定ですが、デプロイの切り替え中に一時的に2台になることがあるため、念のため付けています。
    • withoutOverlapping():前回の実行が終わっていなければ、次の実行を飛ばします。

    📰 出典:Laravel 12.x Task Scheduling(Running Tasks on One Server)

    時刻は「日本時間」で解釈させる

    Laravel 12 のアプリの既定タイムゾーンは UTC です。そのままだと dailyAt('01:00') は日本時間の10時になってしまいます。config/app.php に schedule_timezone を追加し、スケジュールの時刻と、コマンド内の「今日」の判定を日本時間にしました。データベースに保存する日時は UTC のままです。

    config/app.php(追加部分)

        'timezone' => 'UTC',
    
        // スケジューラの実行時刻と「今日」の判定に使うタイムゾーン(連載で追加)。
        // DB に保存する日時は UTC のまま、締め処理などの業務上の日付は日本時間で扱う
        'schedule_timezone' => env('SCHEDULE_TIMEZONE', 'Asia/Tokyo'),

    📰 出典:Laravel 12.x Task Scheduling(Timezones)

    compose にキューワーカーとスケジューラを追加する

    ワーカーとスケジューラは、Web サーバーとは別のプロセスとして常に動かしておく必要があります。第1回の compose.yaml に、同じ開発用 PHP イメージで2つのサービスを追加しました。

    compose.yaml(追加部分)

      # キューワーカー:jobs テーブル(QUEUE_CONNECTION=database)に積まれたジョブを順番に処理する
      queue:
        build:
          context: ./docker/dev/php
          args:
            UID: ${UID:-1000}
            GID: ${GID:-1000}
        command: php artisan queue:work --sleep=3 --max-time=3600
        volumes:
          - ./:/var/www/html
        depends_on:
          mysql:
            condition: service_healthy
        restart: unless-stopped
    
      # スケジューラ:毎分 routes/console.php の予定を確認し、時刻になったコマンドを実行する
      scheduler:
        build:
          context: ./docker/dev/php
          args:
            UID: ${UID:-1000}
            GID: ${GID:-1000}
        command: php artisan schedule:work
        volumes:
          - ./:/var/www/html
        depends_on:
          mysql:
            condition: service_healthy
        restart: unless-stopped

    queue:work は起動時のコードのまま動き続けるため、PHP を変更したら docker compose restart queue scheduler で再起動します。--max-time=3600 は1時間ごとにワーカーを終了させて起動し直させ、メモリの使いすぎを防ぐ指定です。schedule:work は cron を使わずに毎分スケジュールを確認するコマンドです。本番の ECS でも、この2つを「アプリと同じイメージの別サービス」として動かします(第6回・第10回)。

    📰 出典:Laravel 12.x Task Scheduling(Running the Scheduler Locally)

    Vue の請求一覧:処理中のあいだだけポーリングする

    発行ボタンを押した直後、一覧の状態は「発行処理中」です。ワーカーが処理を終えたことを画面に反映するため、Inertia 2 のポーリング(=一定間隔でデータを取り直す仕組み)を使いました。

    resources/js/pages/invoices/Index.vue(script 部分の抜粋)

    <script setup lang="ts">
    // import 文、breadcrumbs・columns(列の定義)、金額の書式は省略
    
    const props = defineProps<{
        invoices: Paginated<InvoiceRow>;
    }>();
    
    const issue = (invoice: InvoiceRow) => {
        router.post(route('invoices.issue', invoice.id), {}, { preserveScroll: true });
    };
    
    // 「発行処理中」の請求があるあいだだけ、2秒ごとに一覧を取り直す(Inertia 2 のポーリング)
    const { start, stop } = usePoll(2000, { only: ['invoices'] }, { autoStart: false });
    const hasIssuing = computed(() => props.invoices.data.some((invoice) => invoice.status === 'issuing'));
    watch(hasIssuing, (issuing) => (issuing ? start() : stop()), { immediate: true });
    </script>

    usePoll は既定では画面を開いた瞬間から動きますが、autoStart: false にして、処理中の請求があるときだけ start() しています。only: ['invoices'] は第4回の部分リロードと同じ指定です。表示には第4回の DataTable を使い、「発行」ボタンは行ごとの権限(can.issue)が true のときだけ出します。

    📰 出典:Inertia.js v2 Polling

    テスト:Queue::fake() でジョブが積まれたことを確かめる

    tests/Feature/InvoiceJobTest.php(抜粋)

        public function test_staff_can_queue_issuing(): void
        {
            Queue::fake();
            $invoice = Invoice::factory()->create();
    
            $this->actingAs(User::factory()->create())
                ->from('/invoices')
                ->post("/invoices/{$invoice->id}/issue")
                ->assertRedirect('/invoices')
                ->assertSessionHas('success');
    
            // その場では発行せず、キューに積まれて「発行処理中」になる
            Queue::assertPushed(IssueInvoice::class, fn (IssueInvoice $job) => $job->invoice->is($invoice));
            $this->assertSame(InvoiceStatus::Issuing, $invoice->fresh()->status);
            $this->assertNull($invoice->fresh()->number);
        }
    
        public function test_double_click_queues_only_once(): void
        {
            Queue::fake();
            $invoice = Invoice::factory()->create();
            $user = User::factory()->create();
    
            $this->actingAs($user)->post("/invoices/{$invoice->id}/issue")->assertSessionHas('success');
            $this->actingAs($user)->post("/invoices/{$invoice->id}/issue")->assertSessionHas('error');
    
            Queue::assertPushed(IssueInvoice::class, 1);
        }
    
        public function test_mark_overdue_command(): void
        {
            $this->travelTo(now('Asia/Tokyo')->setDate(2025, 7, 8)->setTime(1, 0));
            $overdue = Invoice::factory()->issued('2025-07-07')->create();
            $dueToday = Invoice::factory()->issued('2025-07-08')->create();
            $draft = Invoice::factory()->create();
    
            $this->artisan('invoices:mark-overdue')
                ->expectsOutput('期限超過にした請求:1 件')
                ->assertSuccessful();
    
            $this->assertSame(InvoiceStatus::Overdue, $overdue->fresh()->status);
            $this->assertSame(InvoiceStatus::Issued, $dueToday->fresh()->status);
            $this->assertSame(InvoiceStatus::Draft, $draft->fresh()->status);
        }

    Queue::fake() を呼ぶと、ジョブは実行されず「積まれたか」だけが記録され、受け付けとジョブの中身を分けてテストできます。travelTo() は現在時刻を固定する機能で、「期限当日はまだ超過にしない」のような境目を確かめられます。

    📰 出典:Laravel 12.x Queues(Testing)

    このほか、閲覧のみのユーザーが 403 になること、ジョブが採番して発行済みにすること、発行済みの請求に対してジョブがもう一度動いても番号が変わらないこと、failed() で下書きに戻ること、月次締めが前月分の下書きだけを積むこと、スケジュールの時刻と onOneServer の設定のテストを追加し、全体で62件になりました。

    動作確認の方法

    docker compose run --rm --no-deps app php artisan test
    docker compose run --rm --no-deps node sh -c "npm run build"
    docker compose up -d mysql app queue scheduler
    docker compose exec app php artisan migrate:fresh --seed   # 請求40件を含むデータを作り直す(ローカル専用)
    docker compose exec app php artisan schedule:list
    docker compose exec app php artisan schedule:test --name=invoices:close-month

    筆者の環境では、次のことを確認しました。

    • 全62件のテストが成功し、npm run build も成功
    • schedule:list に 0 1 * * * php artisan invoices:mark-overdue と 0 6 1 * * php artisan invoices:close-month が表示される
    • queue サービスを止めた状態で担当者が「発行」を押すと、jobs テーブルに1件積まれ、請求は「発行処理中」のまま。もう一度押すと「すでに発行済みか、発行処理中です。」が表示され、ジョブは増えない
    • queue サービスを起動すると、ワーカーのログに App\Jobs\IssueInvoice ... DONE が出て、jobs テーブルが空になり、請求に INV-年月-ID の番号・発行日・翌月末の支払期限が入る
    • php artisan invoices:mark-overdue で、支払期限を過ぎたシードデータ10件が「期限超過」になる
    • schedule:test で月次締めを即時実行すると、前月分の下書き19件がキューに積まれ、ワーカーがすべて発行済みにする

    ポーリングによる画面の自動更新は、ビルドと型チェックが通ることまでを確認しました。ブラウザでの目視確認はしていません。

    つまずきやすい点・セキュリティ上の注意

    • ワーカーを再起動しないと古いコードのまま動く:本番のデプロイでも同じで、新しいイメージに入れ替わるまで古いワーカーがジョブを処理します。ジョブのクラス名や引数を変えるときは、古いジョブが残っていても動くように作るのが安全です。
    • ジョブには ID だけを渡す:コンストラクタで受け取った Eloquent モデルは ID だけがキューに保存され、実行時に読み直されます。画面の入力値や個人情報をそのままジョブに詰め込むと、キューの保存先にその内容が残ります。
    • 関連データは with()/load() で読む:第4回で preventLazyLoading を有効にしたため、ジョブやコマンドの中でも関連データの読み忘れは例外になります。
    • 失敗に気づける仕組みを用意する:failed_jobs にたまったジョブは、誰かが見なければそのままです。本番ではログとアラームで通知します(第12回)。
    • スケジューラを複数台で動かさない:onOneServer() は保険であり、スケジューラの台数は1台に固定するのが基本です。

    発注者向けメモ:非同期にすると「失敗したとき」の仕様が必要になる

    「ボタンを押したらその場で完了」と「受け付けて裏で順番に処理」は、見た目は似ていても設計が大きく違います。非同期にすると画面は速くなり、大量の処理にも耐えられますが、その代わりに次のことを決める必要があり、ここが工数と運用リスクの分かれ目です。

    • 失敗したときの扱い:何回まで自動でやり直すか。最後まで失敗したら、誰に・どう知らせるか(画面表示、メール、チャット)
    • 処理中の見せ方:利用者に「受け付けました」をどう伝え、完了をどう知らせるか
    • 二重実行の防止:二度押し・再試行で請求書やメールが2通出ないこと
    • 定期実行の時刻と休日の扱い:締め日が休日のときはどうするか、何時までに終わっていればよいか
    • 止まったときの復旧手順:締め処理が失敗したら、誰がいつ再実行するか

    開発会社には、次のように確認してみてください。

    • 「この処理が途中で失敗した場合、データはどういう状態になり、誰がどうやって気づきますか?」
    • 「ボタンを2回押したり、同じ処理が再実行されたりしても、請求書が二重に発行されないことをどう確認していますか?」
    • 「定期実行の処理が動かなかった日があったら、どうやって分かりますか?」
    • 「キューワーカーやスケジューラは本番で何台動かし、デプロイのときはどう入れ替えますか?」

    まとめと次回予告

    第5回では、請求を追加し、請求書の発行をキューで非同期に、月次締めと期限超過チェックをスケジューラで定期実行するようにしました。

    • 「下書きなら処理中にする」を1回の UPDATE で行い、二度押しでもジョブは1つだけ積まれる
    • ジョブは状態を確認してから処理し、再試行されても結果が変わらない。最後まで失敗したら failed() で下書きに戻す
    • routes/console.php のスケジュールに onOneServer()・withoutOverlapping() を付け、時刻は schedule_timezone で日本時間にする
    • compose に queue・scheduler サービスを追加し、Inertia 2 の usePoll で処理中の請求だけ画面を自動更新する

    次回は「本番用コンテナイメージを作る:マルチステージ Dockerfile」です。ここまでのアプリを nginx と php-fpm の本番用イメージにまとめ、同じイメージで Web・キューワーカー・スケジューラを起動できるようにします。

    この連載の記事一覧

    この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次