公開日 2026-08-24

LaravelでMailとNotificationを送る(Mailpit + Queue 最小構成)

Laravel 13 の fresh app で Mailpit と database queue を使い、Mailable と Notification の送信、失敗確認、queue:retry まで手元で再現する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. fresh app と Mailpit を準備する
  4. 2-1. 作業ディレクトリを作る
  5. 2-2. compose.yml を作成する
  6. 2-3. .env の mail / queue 設定を更新する
  7. 3. Mail / Notification / queue の流れを先に見る
  8. 4. Mail と Notification のコードを作る
  9. 4-1. app/Http/Controllers/MailDemoController.php を更新する
  10. 4-2. app/Mail/WorkshopWelcomeMail.php を更新する
  11. 4-3. app/Notifications/WorkshopRegisteredNotification.php を更新する
  12. 4-4. resources/views/mail/workshop-welcome.blade.php を作成する
  13. 4-5. resources/views/welcome.blade.php を更新する
  14. 4-6. routes/web.php を更新する
  15. 5. 画面から送って Mailpit で確認する
  16. 5-1. worker なしで送信し、jobs にたまるのを見る
  17. 5-2. worker を起動して 2 通届くのを見る
  18. 6. 失敗を確認して queue:retry する
  19. 7. よくある詰まりどころ
  20. MAIL_MAILER=log のまま Mailpit に何も届かない
  21. jobs が減らない
  22. .env やコードを変えたのに挙動が古い
  23. create-project の直後に編集できない
  24. 8. まとめ

Laravel でメール送信を足すときは、まず Mailable を使うのか Notification を使うのか、その送信を同期で流すのか queue に積むのかで迷いやすくなります。この記事で作るのは、Laravel 13 の fresh app から Mailpitdatabase queue をつなぎ、同じフォーム送信から MailableNotification を 1 通ずつ送る最小デモです。

前の記事の成果物は前提にせず、Mail と Notification の両方を試します。空ディレクトリから始め、jobsfailed_jobs、Mailpit inbox を見ながら送信成功と失敗の両方を確認します。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop
  • composer:2 コンテナ
  • Laravel 13
  • SQLite
  • Mailpit

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

1. ゴールと非対象

到達点は次の 4 つ。

  • MailableNotification を同じ送信操作で queue に積める
  • jobs と Mailpit の見え方から、worker が別プロセスだと分かる
  • Mailpit 停止時に failed_jobs を確認できる
  • queue:retry all で再送の入口をたどれる

今回の射程は Mail と Notification の最小導線です。SES / Resend / Gmail SMTP、添付ファイル、database notification channel、Horizon、Supervisor、テスト、CI は扱いません。既存記事の成果物も前提にしません。

2. fresh app と Mailpit を準備する

2-1. 作業ディレクトリを作る

最初に、Windows 側の PowerShell から WSL の Ubuntu へ入ります。

wsl

WSL に複数のディストリビューションがある場合は、wsl -l -v で名前を確認してから、起動したい Ubuntu を指定します。

wsl -l -v
wsl -d Ubuntu-24.04

表示が username@pc-name:~$ のように変わっていれば、WSL 側へ入れています。続けて、WSL 側で Docker が使えることを確認します。

docker --version
docker compose version

開始位置は ~/projects 配下です。開始コマンドは次のとおりです。

mkdir -p ~/projects
cd ~/projects
docker run --rm -v "$(pwd):/work" -w /work composer:2 create-project laravel/laravel:^13.0 laravel-mail-notification-demo
docker run --rm -v "$(pwd):/work" alpine:3.20 chown -R "$(id -u):$(id -g)" /work/laravel-mail-notification-demo
cd laravel-mail-notification-demo
code .

composer:2 コンテナは root で動くため、1 行目で作られたファイルは所有者が root になり、そのままでは WSL 側から編集できません。2 行目の chown は、その所有者を自分のユーザーへ戻すための手順です。-u "$(id -u):$(id -g)" を付けて最初から自分の権限で作る方法もありますが、コンテナ内に書き込み可能なホームディレクトリがなく vendor の展開に失敗する場合があるため、root で作成してから所有者を戻す形にしています。

2-2. compose.yml を作成する

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

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

  mailpit:
    image: axllent/mailpit:v1.26
    ports:
      - "1025:1025"
      - "8025:8025"

appphp artisan serve を動かすだけの最小サービスです。Mailpit は SMTP 受け口を 1025、Web UI を 8025 で公開します。

2-3. .env の mail / queue 設定を更新する

.env の該当箇所は次のようにします。

APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
QUEUE_CONNECTION=database
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_FROM_ADDRESS="hello@example.test"
MAIL_FROM_NAME="${APP_NAME}"

fresh app の初期値は MAIL_MAILER=log で、そのままだと Mailpit に何も届きません。必ず smtp へ変えます。

Laravel 13 の fresh app には、0001_01_01_000002_create_jobs_table.php の中に jobs / job_batches / failed_jobs が最初から入っています。今回は make:queue-tablemake:queue-failed-table を追加しません。

起動と状態確認は次のコマンドで進めます。

export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d
docker compose exec app php artisan migrate
docker compose exec app php artisan migrate:status
docker compose ps

fresh app を create-project した時点で migration が済んでいれば、ここでは Nothing to migrate. と表示されます。途中で DB を作り直した場合でも、この 1 回を入れておくと以降の手順がずれません。migrate:status0001_01_01_000002_create_jobs_tableRan と出ていれば、queue 用テーブルはそろっています。

LOCAL_UIDLOCAL_GIDexport は、そのターミナルを開いている間だけ有効です。シェルを開き直したあとに docker compose up -d をやり直す場合は、この 2 行も一緒に再実行してください。

3. Mail / Notification / queue の流れを先に見る

今回の流れは、フォーム送信がそのままメール送信を完了させるのではなく、まず 2 件のジョブを jobs テーブルへ積むところから始まります。worker がそのジョブを取り出して SMTP へ流し、成功すれば Mailpit へ届き、失敗すれば failed_jobs に残ります。

flowchart LR
    U[フォーム送信] --> C[Controllerで受ける]
    C --> M[Mail job を登録]
    C --> N[Notification job を登録]
    M --> J[jobs テーブル]
    N --> J
    W[queue work] --> J
    W --> P[Mailpit SMTP]
    P --> I[Mailpit inbox]
    W --> F[failed_jobs]

Mail と Notification の役割は次のように切り分けると整理しやすくなります。

手段向く場面今回の役割
Mailable本文全体を Blade で組みたいとき受付メール本体を HTML で送る
Notificationmail 以外の channel にも広げたいときmail channel の確認通知を送る

確認先も 3 つに分かれます。

  • jobs: まだ処理されていない待機ジョブ
  • failed_jobs: worker が諦めたジョブ
  • Mailpit: SMTP 送信が成功した結果

4. Mail と Notification のコードを作る

最初に生成コマンドを実行します。

docker compose exec app php artisan make:controller MailDemoController
docker compose exec app php artisan make:mail WorkshopWelcomeMail
docker compose exec app php artisan make:notification WorkshopRegisteredNotification

4-1. app/Http/Controllers/MailDemoController.php を更新する

同じ送信で MailableNotification を 1 通ずつ queue に積み、画面では jobsfailed_jobs の件数を見られるようにします。

<?php

namespace App\Http\Controllers;

use App\Mail\WorkshopWelcomeMail;
use App\Notifications\WorkshopRegisteredNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;

class MailDemoController extends Controller
{
		public function index(): View
		{
				$pendingJobsCount = Schema::hasTable('jobs')
						? DB::table('jobs')->count()
						: 0;

				$failedJobsCount = Schema::hasTable('failed_jobs')
						? DB::table('failed_jobs')->count()
						: 0;

				return view('welcome', [
						'pendingJobsCount' => $pendingJobsCount,
						'failedJobsCount' => $failedJobsCount,
				]);
		}

		public function store(Request $request): RedirectResponse
		{
				$validated = $request->validate([
						'email' => ['required', 'email'],
						'name' => ['required', 'string', 'max:50'],
						'message' => ['required', 'string', 'max:255'],
				]);

				$submittedAt = now()->format('Y-m-d H:i:s');

				Mail::to($validated['email'])->queue(new WorkshopWelcomeMail(
						recipientName: $validated['name'],
						submittedAt: $submittedAt,
						messageBody: $validated['message'],
				));

				Notification::route('mail', $validated['email'])
						->notify(new WorkshopRegisteredNotification(
								recipientName: $validated['name'],
								submittedAt: $submittedAt,
								messageBody: $validated['message'],
						));

				return to_route('mail-demo.index')->with('status', 'Mail と Notification を queue へ積みました。worker が動くと Mailpit へ届きます。');
		}
}

4-2. app/Mail/WorkshopWelcomeMail.php を更新する

Mailable 側は Blade テンプレートを使って本文全体を組みます。

<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class WorkshopWelcomeMail extends Mailable implements ShouldQueue
{
		use Queueable, SerializesModels;

		public function __construct(
				public string $recipientName,
				public string $submittedAt,
				public string $messageBody,
		) {
		}

		public function envelope(): Envelope
		{
				return new Envelope(
						subject: 'Mail で送る受付メール',
				);
		}

		public function content(): Content
		{
				return new Content(
						view: 'mail.workshop-welcome',
				);
		}
}

4-3. app/Notifications/WorkshopRegisteredNotification.php を更新する

Notification 側は mail channel だけに絞ります。今回は notifiable model を作らず、Notification::route('mail', ...) で宛先だけ渡します。

<?php

namespace App\Notifications;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class WorkshopRegisteredNotification extends Notification implements ShouldQueue
{
		use Queueable;

		public function __construct(
				public string $recipientName,
				public string $submittedAt,
				public string $messageBody,
		) {
		}

		public function via(object $notifiable): array
		{
				return ['mail'];
		}

		public function toMail(object $notifiable): MailMessage
		{
				return (new MailMessage)
						->subject('Notification で送る確認メール')
						->greeting($this->recipientName . ' さん')
						->line('Notification の mail channel で送った確認メールです。')
						->line('受け付け時刻: ' . $this->submittedAt)
						->line('メッセージ: ' . $this->messageBody)
						->line('Notification は mail 以外の channel へ広げたいときの入口にもなります。');
		}
}

4-4. resources/views/mail/workshop-welcome.blade.php を作成する

Mail 本文は次の内容で作成します。

<!doctype html>
<html lang="ja">
	<body style="font-family: sans-serif; line-height: 1.6; color: #1f2937;">
		<h1>Mail で送る受付メール</h1>
		<p>{{ $recipientName }} さん、送信テストを受け付けました。</p>
		<p>受付時刻: {{ $submittedAt }}</p>
		<p>入力メッセージ: {{ $messageBody }}</p>
		<p>Mail クラスを使うと、Blade テンプレートで本文全体を組みやすくなります。</p>
	</body>
</html>

4-5. resources/views/welcome.blade.php を更新する

画面では送信フォームと jobs / failed_jobs の件数を見えるようにします。

<!doctype html>
<html lang="ja">
<head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<title>Laravel Mail and Notification Demo</title>
	<style>
		body { font-family: system-ui, sans-serif; margin: 0; background: #f4f7fb; color: #1f2937; }
		main { max-width: 860px; margin: 0 auto; padding: 40px 20px 80px; }
		.card { background: #ffffff; border-radius: 16px; padding: 24px; box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); margin-bottom: 20px; }
		h1, h2 { margin-top: 0; }
		.stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-bottom: 20px; }
		.stat { background: #eef4ff; border-radius: 12px; padding: 16px; }
		label { display: block; font-weight: 600; margin-bottom: 6px; }
		input, textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid #cbd5e1; border-radius: 10px; margin-bottom: 16px; }
		button { background: #2563eb; color: #fff; border: 0; border-radius: 999px; padding: 12px 18px; font-weight: 700; cursor: pointer; }
		.status { background: #dcfce7; color: #166534; border-radius: 12px; padding: 12px 16px; margin-bottom: 16px; }
		code { background: #e2e8f0; padding: 2px 6px; border-radius: 6px; }
	</style>
</head>
<body>
	<main>
		<div class="card">
			<h1>Mail と Notification を queue で送る</h1>
			<p>この画面では、同じ宛先へ Mailable と Notification を 1 通ずつ queue に積みます。worker が動くまでは <code>jobs</code> が増え、処理後は Mailpit に 2 通届きます。</p>
		</div>

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

		<div class="stats">
			<div class="stat">
				<h2>待機中ジョブ</h2>
				<p>{{ $pendingJobsCount }}</p>
			</div>
			<div class="stat">
				<h2>失敗ジョブ</h2>
				<p>{{ $failedJobsCount }}</p>
			</div>
		</div>

		<div class="card">
			<h2>送信フォーム</h2>
			<form method="post" action="{{ route('mail-demo.store') }}">
				@csrf
				<label for="email">宛先メールアドレス</label>
				<input id="email" type="email" name="email" value="{{ old('email', 'hanako@example.test') }}" required>

				<label for="name">名前</label>
				<input id="name" type="text" name="name" value="{{ old('name', 'Hanako') }}" required>

				<label for="message">メッセージ</label>
				<textarea id="message" name="message" rows="4" required>{{ old('message', 'Laravel の Mail と Notification を Mailpit で確認します。') }}</textarea>

				<button type="submit">Mail と Notification を queue へ積む</button>
			</form>

			@if ($errors->any())
				<ul>
					@foreach ($errors->all() as $error)
						<li>{{ $error }}</li>
					@endforeach
				</ul>
			@endif
		</div>

		<div class="card">
			<h2>確認ポイント</h2>
			<ul>
				<li>フォーム送信直後に待機中ジョブが 2 件増えること</li>
				<li><code>php artisan queue:work -v --tries=1</code> を動かすと待機中ジョブが減ること</li>
				<li>Mailpit の inbox に Mailable と Notification が 1 通ずつ届くこと</li>
			</ul>
		</div>
	</main>
</body>
</html>

4-6. routes/web.php を更新する

routes/web.php は次の内容です。

<?php

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

Route::get('/', [MailDemoController::class, 'index'])->name('mail-demo.index');
Route::post('/dispatches', [MailDemoController::class, 'store'])->name('mail-demo.store');

ルートを確認します。

docker compose exec app php artisan route:list --name=mail-demo

GET /POST /dispatches の 2 本が見えれば準備完了です。

5. 画面から送って Mailpit で確認する

ブラウザで http://localhost:8000 を開くと送信フォーム、http://localhost:8025 を開くと Mailpit の inbox が見えます。どちらも 0 件の状態が開始位置です。

送信フォームの初期表示。待機中ジョブと失敗ジョブが 0 件 Mailpit inbox の初期表示。メールは 0 件

5-1. worker なしで送信し、jobs にたまるのを見る

まだ worker は起動しません。フォームは初期値が入ったままでよいので、「Mail と Notification を queue へ積む」ボタンを押します。画面が戻ると緑の完了メッセージが出て、「待機中ジョブ」が 2 になります。Mailpit は 0 件のままです。

送信後の画面。完了メッセージが表示され、待機中ジョブが 2 になっている

ここで見えているのは、「フォーム送信はジョブを積んだだけで、メールはまだ送られていない」という状態です。jobs テーブルを直接数えても同じ 2 件が見えます。

docker compose exec app php artisan tinker --execute="dump(Illuminate\Support\Facades\DB::table('jobs')->count());"

2 が返れば、MailableNotification が 1 件ずつ queue に積まれています。

5-2. worker を起動して 2 通届くのを見る

worker は動かしたままにするので、新しいターミナルで起動します。

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

起動すると、たまっていた 2 件が処理され、ログに App\Mail\WorkshopWelcomeMailApp\Notifications\WorkshopRegisteredNotification が順に DONE と流れます。http://localhost:8000 を再読み込みすると「待機中ジョブ」は 0 に戻り、Mailpit の inbox には Mail で送る受付メールNotification で送る確認メール の 2 通が並びます。

Mailpit inbox に「Mail で送る受付メール」と「Notification で送る確認メール」の 2 通が並んでいる

ターミナルで Mailpit を確認するなら、API も使えます。

curl -s http://localhost:8025/api/v1/messages | sed -n '1,40p'

"total":2 と 2 通の件名が返れば正常です。

6. 失敗を確認して queue:retry する

送信失敗は Mailpit を止めるだけで再現できます。worker は動かしたまま、次のコマンドを実行します。

docker compose stop mailpit

この状態で画面からもう 1 回送信すると、worker は SMTP 接続に失敗します。worker のターミナルには次のように出ます。

  2026-01-01 00:00:32 App\Mail\WorkshopWelcomeMail 2 database default . RUNNING
  2026-01-01 00:00:38 App\Mail\WorkshopWelcomeMail 2 database default  5s 28MB FAIL

**-v を付けていても、worker の行に出るのは FAIL だけで理由は出ません。**理由は storage/logs/laravel.logfailed_jobs テーブルの両方に入ります。ログ側は次の 1 行です。

local.ERROR: Connection could not be established with host "mailpit:1025": stream_socket_client(): php_network_getaddresses: getaddrinfo for mailpit failed: Try again

読む点は 2 つです。

  • getaddrinfo for mailpit failed は名前解決の失敗です。コンテナを止めたので、compose のネットワーク上から mailpit という名前自体が消えています。Mailpit が起動していてポートだけ閉じている場合は Connection refused になるので、ここでどちらの状態かを切り分けられます。
  • 例外の型は Symfony\Component\Mailer\Exception\TransportException です。Laravel のメール送信は Symfony Mailer に委譲しているため、送信失敗の例外はこの名前空間から飛んできます。

失敗ジョブは次のコマンドで確認できます。

docker compose exec app php artisan queue:failed

queue:failed には、Mail と Notification の 2 件が並びます。

2026-01-01 00:00:05 3ce51c97-6565-4e88-8215-2123698fa94c  database@default App\Notifications\WorkshopRegisteredNotification
2026-01-01 00:00:01 1ac7f425-3a87-49e9-8609-22bcf9b85ad8  database@default App\Mail\WorkshopWelcomeMail

worker は 1 件ずつ処理するため、1 件目が失敗した直後に実行すると 1 行しか出ないことがあります。数秒おいて再実行すると 2 件そろいます。件数だけを見るなら、次のコマンドでも確認できます。

docker compose exec app php artisan tinker --execute="dump(Illuminate\Support\Facades\DB::table('failed_jobs')->count());"

ここで 2 が返れば、Mail と Notification の両方が失敗として残っています。

Mailpit を戻し、再送をかけます。

docker compose start mailpit
docker compose exec app php artisan queue:retry all

worker が起動中なら、再投入されたジョブはそのまま処理されます。もう一度 failed_jobs を数えます。

docker compose exec app php artisan tinker --execute="dump(Illuminate\Support\Facades\DB::table('failed_jobs')->count());"

0 に戻り、Mailpit inbox に 2 通が再送されていれば成功です。

7. よくある詰まりどころ

MAIL_MAILER=log のまま Mailpit に何も届かない

Mailpit を使うときは MAIL_MAILER=smtpMAIL_HOST=mailpit が必要です。log のままだと storage/logs/laravel.log に書かれるだけで、Mailpit inbox は空のままです。

jobs が減らない

worker が動いていないと、送信しても jobs にたまるだけです。別ターミナルで php artisan queue:work -v --tries=1 を起動してください。

.env やコードを変えたのに挙動が古い

queue:work は長寿命プロセスです。worker を起動し直してから確認します。Mail 設定や Notification クラスを変えたあとに古いまま見えるときは、まず worker の再起動を疑うほうが早くなります。

create-project の直後に編集できない

composer:2 コンテナは root で動くため、create-project の生成物は root:root になります。そのままでは WSL 側から編集できないため、この記事では生成直後に alpine コンテナで chown する手順を入れています(→ §2-1)。

8. まとめ

Laravel で Mail と Notification を最初に試すなら、Mailpit と database queue を組み合わせると流れを確認しやすくなります。Mailable は本文全体を組む用途、Notification は channel ベースで通知を広げる用途、と切り分けておくと迷いにくくなります。

送信後の確認先も 3 つで固定できます。待機中は jobs、失敗時は failed_jobs、成功時は Mailpit です。この 3 点を同じデモで見られるようにしておくと、次に実運用の mailer や常駐 worker へ広げるときも追いやすくなります。

Queue 側の基礎をもう少し先に整理したい場合は LaravelでQueueを始める(database queue + worker 最小構成) がつながります。認証付き画面へ送信機能を足す流れを確認したい場合は Laravelで認証を足す(Breeze 最小導入) も補助導線になります。

シリーズ 15/17

このシリーズ

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でMailとNotificationを送る(Mailpit + Queue 最小構成) 現在の記事
  16. 16. LaravelでInertia + Vue.js を始める
  17. 17. LaravelでInertia + React を始める