ゴール
- 予約日の当日朝 8:00(JST)に、対象ユーザーへプレーンテキスト+HTMLメールを送信
- 二重送信防止用に reminder_sent_at を保存
- コマンド1本 + スケジューラ で自動運用
- 将来の負荷に備えて Queue対応(未設定でも同期で動作)
1)予約テーブルを作る(Migration+Model)
1-1) マイグレーション作成
php artisan make:migration create_reservations_table
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('reservations', function (Blueprint $table) {
$table->id();
// 最小構成(ユーザー情報は直接持たせる運用)
$table->string('user_name')->nullable();
$table->string('user_email');
// 店舗情報(名称のみ保持:本格運用なら restaurants FK を検討)
$table->string('restaurant_name');
// 予約日時(date と time を分離)
$table->date('reserved_date');
$table->time('reserved_time');
// 人数
$table->unsignedInteger('people')->default(1);
// 任意:予約番号/管理URL/地図URL
$table->string('reservation_code')->nullable()->index();
$table->string('manage_url')->nullable();
$table->string('map_url')->nullable();
// リマインド送信済みの記録
$table->timestamp('reminder_sent_at')->nullable();
$table->timestamps();
// 同一予約の一意性を担保したい場合は適宜Unique制約(例)
// $table->unique(['user_email', 'reserved_date', 'reserved_time', 'restaurant_name'], 'unique_reserve_slot');
});
}
public function down(): void {
Schema::dropIfExists('reservations');
}
};
php artisan migrate
1-2) モデル作成
php artisan make:model Reservation
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Reservation extends Model
{
use HasFactory;
protected $fillable = [
'user_name',
'user_email',
'restaurant_name',
'reserved_date',
'reserved_time',
'people',
'reservation_code',
'manage_url',
'map_url',
'reminder_sent_at',
];
protected $casts = [
'reserved_date' => 'date',
'reserved_time' => 'datetime:H:i', // 取り扱いを簡単にするため「時:分」形式
'reminder_sent_at'=> 'datetime',
];
}
2) Mailable(HTML+プレーンテキスト)
php artisan make:mail ReservationReminderMail --markdown=emails.reservations.reminder_html
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Contracts\Queue\ShouldQueue; // Queue対応
use Illuminate\Queue\SerializesModels;
class ReservationReminderMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
public string $userName,
public string $restaurantName,
public string $date, // 'Y-m-d'
public string $time, // 'H:i'
public int $people,
public ?string $reservationCode = null,
public ?string $manageUrl = null,
public ?string $mapUrl = null,
) {}
public function build()
{
return $this->subject('本日のご予約のご案内')
->markdown('emails.reservations.reminder_html')
->text('emails.reservations.reminder_plain');
}
}
例:HTMLテンプレ(Markdown Blade)
{{-- resources/views/emails/reservations/reminder_html.blade.php --}}
@component('mail::message')
{{ $userName }} 様
本日ご予約のご案内です。
- **店舗名**:{{ $restaurantName }}
- **日付**:{{ $date }}
- **時刻**:{{ $time }}
- **人数**:{{ $people }}名
@isset($reservationCode)
- **予約番号**:{{ $reservationCode }}
@endisset
@isset($manageUrl)
@component('mail::button', ['url' => $manageUrl])
予約内容を確認する
@endcomponent
@endisset
@isset($mapUrl)
[地図を開く]({{ $mapUrl }})
@endisset
※このメールは送信専用です。返信には対応しておりません。
@endcomponent
例:プレーンテキストテンプレ
{{-- resources/views/emails/reservations/reminder_plain.blade.php --}}
{{ $userName }} 様
本日ご予約のご案内です。
店舗名:{{ $restaurantName }}
日付 :{{ $date }}
時刻 :{{ $time }}
人数 :{{ $people }}名
@isset($reservationCode)
予約番号:{{ $reservationCode }}
@endisset
@isset($manageUrl)
予約確認:{{ $manageUrl }}
@endisset
@isset($mapUrl)
地図 :{{ $mapUrl }}
@endisset
※このメールは送信専用です。
3) 送信コマンド(当日分を一括送信)
php artisan make:command SendReservationRemindersCommand
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
use App\Mail\ReservationReminderMail;
use App\Models\Reservation;
use Carbon\Carbon;
class SendReservationRemindersCommand extends Command
{
protected $signature = 'rese:send-reminders {--date=}';
protected $description = '当日の予約にリマインドメールを送る(JST 8:00想定)';
public function handle(): int
{
$tz = 'Asia/Tokyo';
$targetDate = $this->option('date')
? Carbon::parse($this->option('date'), $tz)->toDateString()
: Carbon::now($tz)->toDateString();
$query = Reservation::query()
->whereDate('reserved_date', $targetDate)
->whereNull('reminder_sent_at');
$reservations = $query->get();
$this->info("Target date: {$targetDate}, count: {$reservations->count()}");
foreach ($reservations as $r) {
$mapUrl = $r->map_url
?: ('https://www.google.com/maps/search/?api=1&query=' . urlencode($r->restaurant_name));
// Queue(未設定なら send() に変更してもOK)
Mail::to($r->user_email)->queue(new ReservationReminderMail(
userName: $r->user_name ?? 'お客様',
restaurantName: $r->restaurant_name,
date: $r->reserved_date->format('Y-m-d'),
time: $r->reserved_time->format('H:i'),
people: (int)$r->people,
reservationCode: $r->reservation_code,
manageUrl: $r->manage_url,
mapUrl: $mapUrl
));
// 二重送信防止
$r->update(['reminder_sent_at' => Carbon::now($tz)]);
}
$this->info('Done.');
return Command::SUCCESS;
}
}
4) スケジューラ(毎朝 8:00 JST)
// app/Console/Kernel.php
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
$schedule->command('rese:send-reminders')
->dailyAt('08:00')
->timezone('Asia/Tokyo')
->onOneServer()
->withoutOverlapping();
}
起動(いずれか)
- 本番:cron で * * * * * php /path/to/artisan schedule:run
- ローカル:php artisan schedule:work
5) メール環境&Queue(任意)
.env(例:Mailtrap)
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=xxxx
MAIL_PASSWORD=xxxx
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=no-reply@example.com
MAIL_FROM_NAME="Rese"
Queue を使うなら
// .env
- QUEUE_CONNECTION=sync
+ QUEUE_CONNECTION=database
php artisan queue:table
php artisan migrate
php artisan queue:work --queue=default
Queue を使わないなら、Command内の ->queue() を ->send() に置き換えればOK。
6) 動作確認(ダミーデータ投入→送信)
6-1) ダミーレコードを流し込む(tinker)
php artisan tinker
use App\Models\Reservation;
Reservation::create([
'user_name' => '山田太郎',
'user_email' => 'taro@example.com',
'restaurant_name' => 'テストレストラン',
'reserved_date' => now('Asia/Tokyo')->toDateString(), // 今日
'reserved_time' => now('Asia/Tokyo')->setTime(19, 0)->format('H:i'),
'people' => 2,
'reservation_code'=> 'ABC123',
'manage_url' => 'https://example.com/reservations/ABC123',
'map_url' => null, // 空でもOK(自動生成される)
]);
6-2) コマンド手動実行
# 今日分を送信
php artisan rese:send-reminders
# 任意日を指定して送信
php artisan rese:send-reminders --date=2025-10-10
.env のメール設定を Mailtrap などにしておけば、送信結果を受信箱で確認できます。
送信後は reminder_sent_at が埋まり、同じ予約には再送されません。
7) よくあるハマりポイント
- タイムゾーン:config/app.php の timezone が Asia/Tokyo か
- 差出人:.env の MAIL_FROM_* を忘れていないか
- Queue:queue() を使うのに queue:work を起動していない
- メール送れない:VPS/クラウドの Outbound/Port 制限
- 二重送信:reminder_sent_at の更新漏れに注意
まとめ
- reservations を新規作成し、Mailable+Command+Scheduler で自動送信
- 当日朝 8:00(JST)に実行、reminder_sent_at で二重送信防止
- 将来の負荷に備え Queue対応(未設定なら send() でもOK)