0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Laravel】予約アプリの当日朝リマインドメール設計(コマンド×スケジューラ×Bladeテンプレ付き)

Posted at

ゴール

  • 予約日の当日朝 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)
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?