Building an SMS Reminder System in Laravel (Appointments & Events)

Building an SMS Reminder System in Laravel (Appointments & Events)
Laravel SMS Team
Laravel SMS Team
June 23, 2026

Building an SMS Reminder System in Laravel (Appointments & Events)

Automated SMS reminders reduce no-shows, improve customer satisfaction, and drive revenue for appointment-based businesses. In this guide, you’ll build a complete reminder system in Laravel — with scheduling, queues, timezone handling, and opt-out support.

Database Schema for Appointments & Events

Start with a solid database foundation. Here’s a migration for appointments with reminder tracking:

<?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('appointments', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->foreignId('service_id')->constrained();
            $table->timestamp('starts_at');
            $table->timestamp('ends_at');
            $table->string('timezone', 64)->default('UTC');
            $table->string('status', 32)->default('scheduled'); // scheduled, confirmed, completed, cancelled
            $table->text('notes')->nullable();
            $table->timestamp('reminder_sent_at')->nullable();
            $table->unsignedTinyInteger('reminder_count')->default(0);
            $table->timestamp('cancelled_at')->nullable();
            $table->timestamps();
        });
    }
};
---

Create a pivot table for notification preferences:

```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('notification_preferences', function (Blueprint $table) {
            $table->id();
            $table->morphs('notifiable');
            $table->string('type', 64); // appointment_reminder, followup, etc.
            $table->string('channel', 32)->default('sms'); // sms, email, push
            $table->boolean('enabled')->default(true);
            $table->timestamps();

            $table->unique(['notifiable_id', 'notifiable_type', 'type', 'channel']);
        });
    }
};
---

## Laravel's Task Scheduling

Laravel's cron-based scheduler is perfect for triggering reminders at regular intervals. Define your schedule in `routes/console.php`:

```php
<?php

use Illuminate\Support\Facades\Schedule;

Schedule::command('reminders:send-upcoming --minutes-before=1440')
    ->everyMinute()
    ->withoutOverlapping();

Schedule::command('reminders:send-upcoming --minutes-before=60')
    ->everyMinute()
    ->withoutOverlapping();

Schedule::command('reminders:send-upcoming --minutes-before=30')
    ->everyMinute()
    ->withoutOverlapping();

Schedule::command('reminders:cleanup')->daily();
---

Add the Laravel scheduler to your server's crontab:

```bash
* * * * * cd /path/to/project && php artisan schedule:run >> /dev/null 2>&1
---

## Artisan Commands for Sending Reminders

The core of the system is an Artisan command that queries upcoming appointments and sends SMS reminders:

```php
<?php

namespace App\Console\Commands;

use App\Models\Appointment;
use App\Notifications\AppointmentReminder;
use Carbon\Carbon;
use Illuminate\Console\Command;

class SendUpcomingReminders extends Command
{
    protected $signature = 'reminders:send-upcoming
                           {--minutes-before=60 : How far in advance to remind}
                           {--limit=100 : Max reminders per run}';

    protected $description = 'Send SMS reminders for upcoming appointments';

    public function handle()
    {
        $minutesBefore = (int) $this->option('minutes-before');
        $limit = (int) $this->option('limit');
        $now = Carbon::now();

        $appointments = Appointment::query()
            ->where('status', 'scheduled')
            ->where('starts_at', '>', $now->copy()->addMinutes($minutesBefore - 2))
            ->where('starts_at', '<=', $now->copy()->addMinutes($minutesBefore))
            ->whereNull('reminder_sent_at')
            ->whereHas('user', fn($q) => $q->whereNotNull('phone'))
            ->limit($limit)
            ->get();

        $sent = 0;

        foreach ($appointments as $appointment) {
            $user = $appointment->user;

            if (!$user->wantsReminder('appointment_reminder')) {
                continue;
            }

            try {
                $user->notify(new AppointmentReminder($appointment, 'upcoming'));
                $appointment->update([
                    'reminder_sent_at' => now(),
                    'reminder_count' => $appointment->reminder_count + 1,
                ]);
                $sent++;
            } catch (\Exception $e) {
                $this->error("Failed to send reminder for appointment #{$appointment->id}: {$e->getMessage()}");
            }
        }

        $this->info("Sent {$sent} reminders ({$minutesBefore} min window)");
    }
}
---

## Queueing Reminder Notifications

Always queue SMS sends so your command finishes quickly. Use the `ShouldQueue` interface:

```php
<?php

namespace App\Notifications;

use App\Models\Appointment;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Notification;

class AppointmentReminder extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Appointment $appointment,
        public string $reminderType = 'upcoming'
    ) {
        $this->onQueue('reminders');
    }

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

    public function toSms(object $notifiable): string
    {
        $tz = $notifiable->timezone ?? $this->appointment->timezone ?? 'UTC';
        $start = $this->appointment->starts_at->setTimezone($tz);

        return match ($this->reminderType) {
            'upcoming' => sprintf(
                "🔔 Reminder: %s at %s on %s.\nLocation: %s\n\nReply CONFIRM to confirm or CANCEL to cancel.",
                $this->appointment->service->name,
                $start->format('g:i A'),
                $start->format('D, M j'),
                $this->appointment->location ?? 'our office'
            ),
            '24h' => sprintf(
                "⏰ Just a heads up — you have %s tomorrow at %s.\n\nReply CONFIRM or CANCEL.",
                $this->appointment->service->name,
                $start->format('g:i A')
            ),
            default => sprintf(
                "Reminder: %s on %s",
                $this->appointment->service->name,
                $start->format('D, M j g:i A')
            ),
        };
    }
}
---

Run the queue worker:

```bash
php artisan queue:work --queue=reminders,high,default
---

## Personalizing Reminder Messages

Pull in user and appointment data to make each message feel tailored:

```php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Appointment extends Model
{
    public function reminderContext(): array
    {
        return [
            'customer_name' => $this->user->name,
            'service_name' => $this->service->name,
            'service_duration' => $this->service->duration_minutes,
            'service_price' => number_format($this->service->price, 2),
            'provider_name' => $this->provider?->name ?? 'our team',
            'date' => $this->starts_at->setTimezone($this->timezone)->format('l, F j'),
            'time' => $this->starts_at->setTimezone($this->timezone)->format('g:i A'),
            'location' => $this->location ?? $this->service->location,
            'preparation_tips' => $this->service->preparation_tips,
        ];
    }
}
---

Use these in the notification:

```php
public function toSms(object $notifiable): string
{
    $ctx = $this->appointment->reminderContext();

    return implode("\n", [
        "Hi {$ctx['customer_name']},",
        "",
        "This is a reminder for your {$ctx['service_name']} appointment:",
        "",
        "📅 {$ctx['date']}",
        "⏰ {$ctx['time']} ({$ctx['service_duration']} min)",
        "📍 {$ctx['location']}",
        "👤 With {$ctx['provider_name']}",
        "",
        $ctx['preparation_tips'] ? "💡 {$ctx['preparation_tips']}" : null,
        "",
        "Reply CONFIRM or CANCEL. Questions? Call us at " . config('app.support_phone'),
    ]);
}
---

## Handling Timezone Differences

Store each user's timezone and normalize all reminder logic against it:

```php
<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    public function routeNotificationForSms(): ?string
    {
        return $this->phone;
    }

    public function wantsReminder(string $type): bool
    {
        $pref = $this->notificationPreferences()
            ->where('type', $type)
            ->first();

        return $pref?->enabled ?? true;
    }

    public function localTime(): Carbon
    {
        return Carbon::now($this->timezone ?? 'UTC');
    }
}
---

When scheduling reminders, compute the correct local time:

```php
// In your Artisan command, check if it's appropriate to send
// based on the user's local time
$userLocalNow = $user->localTime();
if ($userLocalNow->hour < 8 || $userLocalNow->hour > 21) {
    // Skip quiet hours; reschedule for next available window
    $this->rescheduleReminder($appointment, $userLocalNow);
    continue;
}
---

## Opt-Out Support

Transactional messages typically don't require opt-out, but it's good practice to provide it. Implement a simple keyword-based opt-out:

```php
<?php

namespace App\Http\Controllers\Sms;

use App\Models\User;
use Illuminate\Http\Request;

class SmsReplyController extends Controller
{
    public function __invoke(Request $request)
    {
        $from = $request->input('From');
        $body = strtoupper(trim($request->input('Body')));

        $user = User::where('phone', $from)->first();

        if (!$user) {
            return response('OK');
        }

        match ($body) {
            'STOP', 'CANCEL', 'UNSUBSCRIBE' => $this->optOut($user),
            'START', 'CONFIRM' => $this->optIn($user),
            default => $this->handleKeyword($user, $body),
        };

        return response('OK');
    }

    protected function optOut(User $user): void
    {
        $user->notificationPreferences()
            ->where('type', 'appointment_reminder')
            ->update(['enabled' => false]);

        // Send confirmation via SMS
        $user->notify(new OptOutConfirmation());
    }

    protected function optIn(User $user): void
    {
        $user->notificationPreferences()
            ->where('type', 'appointment_reminder')
            ->update(['enabled' => true]);
    }
}
---

Register the webhook route:

```php
<?php

use App\Http\Controllers\Sms\SmsReplyController;
use Illuminate\Support\Facades\Route;

Route::post('/sms/reply', SmsReplyController::class)
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);
---

## Testing the Reminder System

```php
<?php

namespace Tests\Console\Commands;

use App\Models\Appointment;
use App\Models\User;
use App\Notifications\AppointmentReminder;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;

class SendUpcomingRemindersTest extends TestCase
{
    public function test_sends_reminders_for_upcoming_appointments()
    {
        Notification::fake();

        $user = User::factory()->create(['phone' => '+1234567890']);
        $appointment = Appointment::factory()->create([
            'user_id' => $user->id,
            'starts_at' => now()->addMinutes(30),
            'status' => 'scheduled',
        ]);

        $this->artisan('reminders:send-upcoming', ['--minutes-before' => 30])
            ->assertSuccessful();

        Notification::assertSentTo($user, AppointmentReminder::class);
        $this->assertNotNull($appointment->fresh()->reminder_sent_at);
    }

    public function test_skips_appointments_without_phone()
    {
        Notification::fake();

        $user = User::factory()->create(['phone' => null]);
        Appointment::factory()->create([
            'user_id' => $user->id,
            'starts_at' => now()->addMinutes(30),
        ]);

        $this->artisan('reminders:send-upcoming', ['--minutes-before' => 30])
            ->assertSuccessful();

        Notification::assertNothingSent();
    }
}
---

## Monitoring and Reporting

Track reminder effectiveness with a simple report:

```php
<?php

namespace App\Console\Commands;

use App\Models\Appointment;
use Illuminate\Console\Command;

class ReminderStats extends Command
{
    protected $signature = 'reminders:stats {--days=30}';
    protected $description = 'Show reminder effectiveness stats';

    public function handle()
    {
        $days = $this->option('days');

        $total = Appointment::where('created_at', '>=', now()->subDays($days))->count();
        $reminded = Appointment::whereNotNull('reminder_sent_at')
            ->where('created_at', '>=', now()->subDays($days))
            ->count();
        $noShows = Appointment::where('status', 'no_show')
            ->where('created_at', '>=', now()->subDays($days))
            ->count();

        $this->table(
            ['Metric', 'Value'],
            [
                ['Total Appointments', $total],
                ['Reminders Sent', $reminded],
                ['No-Shows', $noShows],
                ['No-Show Rate', $total > 0 ? round(($noShows / $total) * 100, 1) . '%' : 'N/A'],
                ['Effectiveness', $reminded > 0 ? round((1 - ($noShows / $reminded)) * 100, 1) . '%' : 'N/A'],
            ]
        );
    }
}
---

## Conclusion

An SMS reminder system is one of the highest-leverage features you can build. With Laravel's scheduler, queue system, and notifications, you can implement a robust solution in a single day.

Need help building custom SMS features? Check out our [custom Laravel SMS development services](/services/custom-laravel-sms-development) or explore our [gateway integration service](/services/laravel-sms-gateway-integration-service) for connecting any provider.

For a complete ready-to-use solution, see our [customer notification software platform](/services/laravel-customer-notification-software).