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.
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).