Laravel でメール送信を足すときは、まず Mailable を使うのか Notification を使うのか、その送信を同期で流すのか queue に積むのかで迷いやすくなります。この記事で作るのは、Laravel 13 の fresh app から Mailpit と database queue をつなぎ、同じフォーム送信から Mailable と Notification を 1 通ずつ送る最小デモです。
前の記事の成果物は前提にせず、Mail と Notification の両方を試します。空ディレクトリから始め、jobs、failed_jobs、Mailpit inbox を見ながら送信成功と失敗の両方を確認します。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop
composer:2コンテナ- Laravel 13
- SQLite
- Mailpit
コマンド実行は、特記がない限り WSL 側ターミナルです。
1. ゴールと非対象
到達点は次の 4 つ。
MailableとNotificationを同じ送信操作で 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"
app は php 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-table や make: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:status に 0001_01_01_000002_create_jobs_table が Ran と出ていれば、queue 用テーブルはそろっています。
LOCAL_UID と LOCAL_GID の export は、そのターミナルを開いている間だけ有効です。シェルを開き直したあとに 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 で送る |
Notification | mail 以外の 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 を更新する
同じ送信で Mailable と Notification を 1 通ずつ queue に積み、画面では jobs と failed_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 件の状態が開始位置です。
5-1. worker なしで送信し、jobs にたまるのを見る
まだ worker は起動しません。フォームは初期値が入ったままでよいので、「Mail と Notification を queue へ積む」ボタンを押します。画面が戻ると緑の完了メッセージが出て、「待機中ジョブ」が 2 になります。Mailpit は 0 件のままです。
ここで見えているのは、「フォーム送信はジョブを積んだだけで、メールはまだ送られていない」という状態です。jobs テーブルを直接数えても同じ 2 件が見えます。
docker compose exec app php artisan tinker --execute="dump(Illuminate\Support\Facades\DB::table('jobs')->count());"
2 が返れば、Mailable と Notification が 1 件ずつ queue に積まれています。
5-2. worker を起動して 2 通届くのを見る
worker は動かしたままにするので、新しいターミナルで起動します。
docker compose exec app php artisan queue:work -v --tries=1
起動すると、たまっていた 2 件が処理され、ログに App\Mail\WorkshopWelcomeMail と App\Notifications\WorkshopRegisteredNotification が順に DONE と流れます。http://localhost:8000 を再読み込みすると「待機中ジョブ」は 0 に戻り、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.log と failed_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=smtp と MAIL_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 最小導入) も補助導線になります。