公開日 2026-03-29

LaravelでQueueを始める(database queue + worker 最小構成)

Laravel 13 の新規プロジェクトで database queue を最小構成で試し、dispatch()、jobs テーブル、queue:work、queue_runs 履歴の流れを welcome 画面だけで確認する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 新規デモを開いて開始位置をそろえる
  4. 3. Queue の全体像を先に見る
  5. 4. database queue を動かす前提をそろえる
  6. 5. 完了履歴テーブルと Job を作る
  7. コードのポイント
  8. 6. ボタン操作で Job をキューへ積む
  9. コードのポイント
  10. 7. welcome 画面に待機数と履歴を出す
  11. コードのポイント
  12. 8. worker を起動して挙動を確認する
  13. 1. worker を起動せずに確認する
  14. 2. 次に worker を起動して確認する
  15. 9. つまずいたときの確認
  16. docker compose exec app ... が no configuration file provided になる
  17. ボタンを押すたびに待機中ジョブ数だけ増える
  18. queue_runs テーブルが無いと言われる
  19. 直近の実行履歴を空に戻したい
  20. コードを直したのに古い挙動のまま動く
  21. make:queue-table を実行したら重複 migration になった

Laravel 13 の database queue を試す手順です。welcome 画面にボタンを 1 つ置き、dispatch() で積んだ Job を queue:work が別プロセスで処理し、jobs テーブルと queue_runs テーブルの違いを確認します。認証、業務画面の CRUD、Redis / Horizon はここでは入れません。

Queue は時間のかかる処理を待ち行列へ積んで後から実行する仕組みで、worker はその待ち行列から Job を取り出して処理する別プロセスです。この記事では database driver の最小構成に限定し、その流れを目で追える形にします。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)
  • Laravel 13
  • SQLite

コマンド実行は、特記がない限り WSL 側ターミナルです。

1. ゴールと非対象

この記事で着地したいのは次の 4 点です。

  • QUEUE_CONNECTION=database で Job をキューへ積める
  • welcome 画面のボタンから Job を dispatch できる
  • queue:work を動かすと、別プロセスで Job が処理される
  • 待機中ジョブ数と完了履歴を同じ画面で見分けられる

今回は Queue の最小構成だけを扱います。認証、CRUD、Redis / Horizon / Supervisor、複数 queue の優先度制御、Notification / Mail の queue 化は対象外です。開始位置から完結する独立デモとして進めます。

2. 新規デモを開いて開始位置をそろえる

最初に確認するのは、WSL と Docker が見えているかどうかです。

PowerShell:

wsl -l -v

WSL(Ubuntu):

docker --version
docker compose version

開始位置は次のディレクトリです。

mkdir -p ~/projects/laravel-queue-database-worker-demo
cd ~/projects/laravel-queue-database-worker-demo
code .
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .

compose.yml は次の内容で作成します。

services:
  app:
    image: composer:2
    user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
    working_dir: /app
    volumes:
      - ./:/app
    ports:
      - "8000:8000"
    command: php artisan serve --host=0.0.0.0 --port=8000

同じシェルで次も実行し、docker compose 側でも自分の UID / GID を使うようにします。

export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d
docker compose ps

ブラウザで http://localhost:8000 を開き、Laravel の welcome 画面が見えれば準備完了です。8000 番ポートが埋まっている場合は、compose.yml の左側を 8001:8000 のように変えてから docker compose up -d をやり直してください。

3. Queue の全体像を先に見る

Laravel の Queue docs では、connection をバックエンド種別、queue をその中の積み分け先として説明しています。今回触るのは database connection の default queue だけです。

確認する観点は次の 3 つです。

  • ブラウザの POST は Job を完了させるのではなく、待ち行列へ積む
  • 実際の処理は queue:work を動かしている別プロセスが行う
  • jobs テーブルは待機ジョブ、queue_runs テーブルは完了履歴として見る

全体の流れは次の図です。

flowchart LR
    U[welcome 画面のボタン] -->|POST /queue-runs| D[RecordQueueRun::dispatch]
    D --> J[jobs テーブル]
    W[queue:work database] -->|取得して実行| J
    W --> H[RecordQueueRun::handle]
    H --> R[queue_runs テーブル]
    R --> V[welcome 画面の履歴表]

以降は、default connection / default queue に積んだ Job を worker が拾う、という最小の輪郭だけを見ます。

4. database queue を動かす前提をそろえる

実行場所は Laravel アプリのルートです。

最初に .env を開き、少なくとも次の 2 項目を確認します。Laravel 13 の新規アプリでは最初からこの値になっていることが多く、その場合は変更不要です。

DB_CONNECTION=sqlite
QUEUE_CONNECTION=database

次に、SQLite ファイルと jobs テーブルの migration 状態を確認します。Laravel 13 の新規アプリには create_jobs_table migration が最初から入っているため、通常は migrate だけで足ります。

test -f database/database.sqlite || touch database/database.sqlite
docker compose exec app php artisan migrate:status
docker compose exec app php artisan migrate

migrate:statuscreate_jobs_table が見当たらない場合だけ、次のコマンドで migration を追加します。

docker compose exec app php artisan make:queue-table
docker compose exec app php artisan migrate

jobs テーブルは、まだ処理されていない Job を置く場所です。後で作る queue_runs は worker が処理を終えた履歴を残すテーブルで、役割が違います。

5. 完了履歴テーブルと Job を作る

この章では、worker が処理し終えた結果を画面で確認できるようにします。題材は「welcome 画面から投入したテストジョブを、完了履歴として保存する Job」です。

Laravel アプリのルートで model と Job を生成します。

docker compose exec app php artisan make:model QueueRun -m
docker compose exec app php artisan make:job RecordQueueRun

生成された migration は database/migrations/*_create_queue_runs_table.php を開き、次の内容に更新します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('queue_runs', function (Blueprint $table) {
            $table->id();
            $table->string('message');
            $table->timestamp('queued_at');
            $table->timestamp('finished_at');
            $table->string('queue_connection', 50);
            $table->string('queue_name', 50)->default('default');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('queue_runs');
    }
};

app/Models/QueueRun.php は次の内容に更新します。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class QueueRun extends Model
{
    protected $fillable = [
        'message',
        'queued_at',
        'finished_at',
        'queue_connection',
        'queue_name',
    ];

    protected $casts = [
        'queued_at' => 'datetime',
        'finished_at' => 'datetime',
    ];
}

Job 本体 (app/Jobs/RecordQueueRun.php) は次の内容に更新します。

<?php

namespace App\Jobs;

use App\Models\QueueRun;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Carbon;

class RecordQueueRun implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public string $message,
        public string $queuedAt,
    ) {
    }

    public function handle(): void
    {
        QueueRun::query()->create([
            'message' => $this->message,
            'queued_at' => Carbon::parse($this->queuedAt),
            'finished_at' => now(),
            'queue_connection' => config('queue.default'),
            'queue_name' => 'default',
        ]);
    }
}

最後に migration を実行します。

docker compose exec app php artisan migrate

コードのポイント

ShouldQueue を実装すると、その場で完了しない

class RecordQueueRun implements ShouldQueue
{
    use Queueable;

dispatch() した瞬間に handle() が controller 内で動くわけではありません。Job はまず jobs テーブルへ積まれ、worker が拾ったときに handle() が実行されます。

queued_atfinished_at を分けると、非同期処理だと分かりやすい

QueueRun::query()->create([
    'message' => $this->message,
    'queued_at' => Carbon::parse($this->queuedAt),
    'finished_at' => now(),
    'queue_connection' => config('queue.default'),
    'queue_name' => 'default',
]);

queued_at はブラウザから積んだ時刻、finished_at は worker が処理し終えた時刻です。両方を残すと、HTTP リクエストの成功と Job 完了を切り分けて見られます。

6. ボタン操作で Job をキューへ積む

この章では、welcome 画面からボタンで Job を積めるようにします。

Laravel アプリのルートで controller を生成します。

docker compose exec app php artisan make:controller QueueRunController

app/Http/Controllers/QueueRunController.php は次の内容に更新します。

<?php

namespace App\Http\Controllers;

use App\Jobs\RecordQueueRun;
use App\Models\QueueRun;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\View\View;

class QueueRunController extends Controller
{
    public function index(): View
    {
        return view('welcome', [
            'pendingJobsCount' => DB::table('jobs')->count(),
            'queueRuns' => QueueRun::query()
                ->latest('id')
                ->limit(10)
                ->get(),
        ]);
    }

    public function store(): RedirectResponse
    {
        RecordQueueRun::dispatch(
            message: 'welcome 画面から投入したテストジョブ',
            queuedAt: now()->toIso8601String(),
        );

        return redirect()
            ->route('queue-runs.index')
            ->with('status', 'テストジョブをキューへ追加しました。worker が処理すると履歴に反映されます。');
    }
}

Laravel を作成した直後の routes/web.php には、welcome 画面を返す route だけがあります。

Route::get('/', function () {
    return view('welcome');
});

今回はこの初期状態から始めているので、差分追加ではなく routes/web.php を次の内容に更新します。

<?php

use App\Http\Controllers\QueueRunController;
use Illuminate\Support\Facades\Route;

Route::get('/', [QueueRunController::class, 'index'])->name('queue-runs.index');
Route::post('/queue-runs', [QueueRunController::class, 'store'])->name('queue-runs.store');

コードのポイント

index() は待機中ジョブ数と完了履歴をまとめて渡す

return view('welcome', [
    'pendingJobsCount' => DB::table('jobs')->count(),
    'queueRuns' => QueueRun::query()
        ->latest('id')
        ->limit(10)
        ->get(),
]);

jobs テーブルの件数と queue_runs の履歴を同じ画面へ渡すと、worker が止まっているときの挙動も見やすくなります。

store() が返しているのは、Job 完了ではなく「キューへ積めた」という結果

RecordQueueRun::dispatch(
    message: 'welcome 画面から投入したテストジョブ',
    queuedAt: now()->toIso8601String(),
);

worker が止まっていれば、この時点で履歴はまだ増えません。ここで成功しているのは、あくまで jobs テーブルへ積むところまでです。

7. welcome 画面に待機数と履歴を出す

表示は starter kit に依存させず、resources/views/welcome.blade.php をそのまま使います。

resources/views/welcome.blade.php は次の内容に更新します。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Laravel Queue Demo</title>
    <style>
        :root {
            color-scheme: light;
            font-family: system-ui, sans-serif;
        }

        body {
            margin: 0;
            background: #f8fafc;
            color: #0f172a;
        }

        main {
            max-width: 960px;
            margin: 0 auto;
            padding: 48px 20px 64px;
        }

        h1,
        h2 {
            margin: 0 0 12px;
        }

        p {
            line-height: 1.7;
        }

        .grid {
            display: grid;
            gap: 16px;
            margin: 24px 0;
        }

        .card {
            background: #ffffff;
            border: 1px solid #e2e8f0;
            border-radius: 16px;
            padding: 20px;
            box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
        }

        .status {
            margin-bottom: 16px;
            padding: 12px 16px;
            border-radius: 12px;
            background: #dcfce7;
            color: #166534;
        }

        .pending {
            font-size: 2rem;
            font-weight: 700;
            margin: 8px 0 0;
        }

        button {
            border: 0;
            border-radius: 999px;
            padding: 12px 18px;
            background: #0f172a;
            color: #ffffff;
            font: inherit;
            cursor: pointer;
        }

        button:hover {
            background: #1e293b;
        }

        table {
            width: 100%;
            min-width: 640px;
            border-collapse: collapse;
        }

        th,
        td {
            padding: 12px 10px;
            border-bottom: 1px solid #e2e8f0;
            text-align: left;
            vertical-align: top;
        }

        th {
            font-size: 0.875rem;
            color: #475569;
        }

        .table-wrap {
            overflow-x: auto;
        }

        code {
            font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
        }

        ul {
            margin: 0;
            padding-left: 20px;
        }

        @media (min-width: 768px) {
            .grid {
                grid-template-columns: 1.1fr 0.9fr;
            }
        }
    </style>
</head>
<body>
    <main>
        <section class="card">
            <h1>Laravel Queue Demo</h1>
            <p><code>jobs</code> テーブルは待機中の Job、<code>queue_runs</code> テーブルは worker が処理し終えた履歴です。worker を止めたままボタンを押し、次に <code>queue:work</code> を起動すると違いが分かります。</p>
        </section>

        @if (session('status'))
            <p class="status">{{ session('status') }}</p>
        @endif

        <div class="grid">
            <section class="card">
                <h2>テストジョブを追加する</h2>
                <p>worker が止まっている間は、待機中ジョブだけが増えます。</p>
                <p class="pending">{{ $pendingJobsCount }}</p>
                <p>待機中ジョブ数</p>

                <form method="POST" action="{{ route('queue-runs.store') }}">
                    @csrf
                    <button type="submit">キューへ追加する</button>
                </form>
            </section>

            <section class="card">
                <h2>確認ポイント</h2>
                <ul>
                    <li>POST 直後は履歴が増えなくても正常です。</li>
                    <li><code>queue:work</code> が Job を拾うと履歴が増えます。</li>
                    <li>コードを直したあとは worker を再起動します。</li>
                </ul>
            </section>
        </div>

        <section class="card">
            <h2>直近の実行履歴</h2>
            <div class="table-wrap">
                <table>
                    <thead>
                        <tr>
                            <th>キュー投入時刻</th>
                            <th>完了時刻</th>
                            <th>connection</th>
                            <th>queue</th>
                            <th>メッセージ</th>
                        </tr>
                    </thead>
                    <tbody>
                        @forelse ($queueRuns as $queueRun)
                            <tr>
                                <td>{{ $queueRun->queued_at->format('Y-m-d H:i:s') }}</td>
                                <td>{{ $queueRun->finished_at->format('Y-m-d H:i:s') }}</td>
                                <td>{{ $queueRun->queue_connection }}</td>
                                <td>{{ $queueRun->queue_name }}</td>
                                <td>{{ $queueRun->message }}</td>
                            </tr>
                        @empty
                            <tr>
                                <td colspan="5">まだ実行履歴はありません。worker を起動してからボタンを押してください。</td>
                            </tr>
                        @endforelse
                    </tbody>
                </table>
            </div>
        </section>
    </main>
</body>
</html>

コードのポイント

jobs の待機数と queue_runs の履歴を 1 画面で並べる

<p class="pending">{{ $pendingJobsCount }}</p>

@forelse ($queueRuns as $queueRun)

待機中ジョブが増える瞬間と、履歴が増える瞬間を分けて見られます。worker を止めたままボタンを押したときに、画面のどこを見るべきかも明確になります。

② ボタンは POST form にする

<form method="POST" action="{{ route('queue-runs.store') }}">
    @csrf
    <button type="submit">キューへ追加する</button>
</form>

状態を変える操作をリンクではなく POST にしておくと、Queue 投入の入口だと分かりやすくなります。

8. worker を起動して挙動を確認する

ここは 2 回に分けて確認すると追いやすくなります。先に「worker を起動しないとどう見えるか」を確認し、そのあと worker を起動して同じ画面がどう変わるかを見ます。

1. worker を起動せずに確認する

  1. http://localhost:8000 を開く
  2. 「キューへ追加する」を押す
  3. 緑のメッセージが出ていることを確認する
  4. 待機中ジョブ数 が増えていることを確認する
  5. 直近の実行履歴 はまだ増えていないことを確認する

ここでは、Job が jobs テーブルへ積まれただけの状態です。ブラウザの POST は成功していますが、まだ worker が処理していないので履歴には反映されません。

worker を止めたままの初期画面と、ボタンを押した直後の画面は次のようになります。

worker を起動する前の初期画面 worker を止めたままボタンを押した直後の画面

2. 次に worker を起動して確認する

Laravel アプリのルートで次のコマンドを実行します。

docker compose exec app php artisan queue:work database -v --tries=3

database は queue connection 名です。-v を付けると、処理した Job のクラス名や ID が見やすくなります。

worker を起動したら、次の順で見ます。

  1. worker 側に、さきほど積んだ Job の処理ログが出ることを確認する
  2. ブラウザへ戻って画面を更新し、待機中ジョブ数 が減り、直近の実行履歴 に 1 行増えていることを確認する

worker 側のログと、処理後の画面は次のようになります。

worker が RecordQueueRun を処理したターミナルログ worker 処理後に履歴が増えた画面

8-1 で積んだ Job がすぐには履歴へ反映されず、worker を起動してから反映される流れを見比べると、HTTP リクエストの成功と Job 完了が別イベントだと分かります。dispatch は「積んだ」、worker は「処理した」、履歴表は「終わった」を表します。

失敗したジョブの確認には、次のコマンドを使います。

docker compose exec app php artisan queue:failed

空なら失敗したジョブはありません。行が出るときは、その Job が例外で落ちています。最初は storage/logs/laravel.logqueue:failed を見れば、止まっている場所を追いやすくなります。

queue:work は長寿命プロセスなので、コードを直したあとは worker を止めて再起動してください。起動後のコード変更は自動では反映されません。

9. つまずいたときの確認

docker compose exec app ...no configuration file provided になる

compose.yml がないディレクトリでコマンドを実行しています。まず pwdls で、記事冒頭で作った laravel-queue-database-worker-demo にいるか確認してください。

ボタンを押すたびに待機中ジョブ数だけ増える

worker が止まっていることが多いです。queue:work database -v --tries=3 を別ターミナルで起動し直してください。jobs テーブルへ積まれていても、worker が拾わなければ queue_runs は増えません。

queue_runs テーブルが無いと言われる

php artisan migrate がまだ通っていません。QueueRun の migration を更新したあとで、もう一度 docker compose exec app php artisan migrate を実行してください。

直近の実行履歴を空に戻したい

queue_runs の履歴だけ消すなら、次のコマンドを実行します。

docker compose exec app php -r '$pdo = new PDO("sqlite:database/database.sqlite"); $pdo->exec("DELETE FROM queue_runs");'

待機中ジョブや失敗ジョブも含めて最初の状態へ戻したい場合は、次のコマンドを使います。

docker compose exec app php -r '$pdo = new PDO("sqlite:database/database.sqlite"); $pdo->exec("DELETE FROM queue_runs"); $pdo->exec("DELETE FROM jobs"); $pdo->exec("DELETE FROM failed_jobs");'

コードを直したのに古い挙動のまま動く

queue:work は長寿命プロセスです。起動後のコード変更は自動で反映されません。worker を止めて再起動してください。

make:queue-table を実行したら重複 migration になった

Laravel 13 の新規アプリでは create_jobs_table migration が既に入っていることがあります。その場合は新しく生成せず、既存 migration を migrate すれば十分です。

シリーズ 6/16

このシリーズ

Laravelの基本を最初から通す

  1. 1. Laravel入門(Route / Controller / View / Model 最小構成)
  2. 2. Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除)
  3. 3. Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions)
  4. 4. Laravelで認証を足す(Breeze 最小導入)
  5. 5. Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする)
  6. 6. LaravelでQueueを始める(database queue + worker 最小構成) 現在の記事
  7. 7. Laravelでスケジューラを動かす(Command + Scheduler 最小構成)
  8. 8. Laravelでファイルアップロードを扱う(Storage + validation)
  9. 9. Laravelでリレーションを扱う(User / Book / Category の基本)
  10. 10. Laravelで検索・並び替え・ページネーション付き一覧を作る
  11. 11. LaravelでLivewireを始める
  12. 12. LaravelでLivewire一覧画面を作る(検索・並び替え・ページネーション)
  13. 13. LaravelでSanctum認証APIを作る
  14. 14. LaravelでBlade / Livewire / Inertia をどう使い分けるか
  15. 15. LaravelでInertia + Vue.js を始める
  16. 16. LaravelでInertia + React を始める