Sending Laravel notifications at the right local hour using timezones, a command, and cron
Learn how to send Laravel notifications to users at their local hour by checking timezones in a scheduled command that runs hourly via cron. Includes schema tips, examples with Carbon, queues, performance, and edge cases like DST.
If your users span multiple timezones, “send at 9am” becomes ambiguous. The simplest, robust pattern is:
- Store each user’s timezone (IANA identifier like America/New_York).
- Decide the local hour(s) they should receive a notification (e.g., 09:00 local time).
- Run a scheduler every hour. Inside a command, compute each user’s current local hour and send when it matches their target hour.
This avoids converting all times into UTC per user in advance and gracefully handles Daylight Saving Time (DST) shifts.
What we’ll build:
- A User model with a timezone column and a preferred_notification_hour column
- A notification (Laravel Notifications) that can be queued
- A command that runs hourly, filters eligible users, and dispatches notifications
- Laravel Scheduler (app/Console/Kernel.php) and system cron entry to run schedule:run every minute or hour
Why this pattern works
- Timezone-safe: Carbon handles DST and odd offsets (e.g., +05:30) when you use setTimezone($user->timezone).
- Scalable: Your command runs frequently (hourly or every minute) but sends only to the cohort whose local hour matches.
- Flexible: You can support different notification types or multiple windows per day without complex calendar math.
Data model Add columns to users (names are examples):
- timezone: string, e.g., America/New_York
- preferred_notification_hour: tinyInteger 0–23
Example migration snippet:
1Schema::table('users', function (Blueprint $table) {2 $table->string('timezone')->default('UTC');3 $table->unsignedTinyInteger('preferred_notification_hour')->default(9); // 0-234});
Validate and set a sensible default (UTC) for users that haven’t chosen one yet.
The Notification A standard Laravel notification that can be delivered via mail, database, Slack, etc. Mark it as ShouldQueue for scale.
1use Illuminate\Bus\Queueable; 2use Illuminate\Contracts\Queue\ShouldQueue; 3use Illuminate\Notifications\Messages\MailMessage; 4use Illuminate\Notifications\Notification; 5 6class DailyDigestNotification extends Notification implements ShouldQueue 7{ 8 use Queueable; 9 10 public function __construct(public array $payload = []) {}11 12 public function via($notifiable): array13 {14 return ['mail'];15 }16 17 public function toMail($notifiable): MailMessage18 {19 return (new MailMessage)20 ->subject('Your Daily Digest')21 ->greeting('Hello!')22 ->line('Here is your digest for today based on your preferences.')23 ->action('View Details', url('/'))24 ->line('Thanks for using our app!');25 }26}
The hourly command This command checks users whose local hour equals their preferred hour and notifies them. It minimizes PHP-timezone conversions by letting the database pre-filter on an approximate UTC window, then confirms in PHP for correctness.
1namespace App\Console\Commands; 2 3use App\Models\User; 4use App\Notifications\DailyDigestNotification; 5use Carbon\CarbonImmutable as Carbon; 6use Illuminate\Console\Command; 7use Illuminate\Support\Facades\DB; 8 9class SendTimezoneNotificationsCommand extends Command10{11 protected $signature = 'notifications:send-timezone-digests {--dry-run : Do not actually send notifications}';12 protected $description = 'Send notifications at each user\'s preferred local hour by checking timezones.';13 14 public function handle(): int15 {16 $nowUtc = Carbon::now('UTC');17 $sent = 0;18 $skipped = 0;19 20 // Fetch in chunks for memory safety21 User::query()22 ->whereNotNull('timezone')23 ->whereNotNull('preferred_notification_hour')24 ->chunkById(1000, function ($users) use (&$sent, &$skipped, $nowUtc) {25 foreach ($users as $user) {26 $tz = $user->timezone ?: 'UTC';27 28 // Compute the user's current local hour safely with Carbon29 $userLocalNow = $nowUtc->setTimezone($tz);30 $currentLocalHour = (int) $userLocalNow->format('G'); // 0-2331 32 if ($currentLocalHour === (int) $user->preferred_notification_hour) {33 if ($this->option('dry-run')) {34 $this->line("Would notify user {$user->id} at {$userLocalNow->toDateTimeString()} ({$tz})");35 $sent++;36 continue;37 }38 39 // Debounce: prevent duplicate sends if schedule runs more than once per hour40 if (! $this->alreadyNotifiedThisWindow($user, $userLocalNow)) {41 $user->notify(new DailyDigestNotification());42 $this->info("Notified user {$user->id} at {$userLocalNow->toDateTimeString()} ({$tz})");43 $sent++;44 } else {45 $skipped++;46 }47 } else {48 $skipped++;49 }50 }51 });52 53 $this->info("Sent: {$sent}, Skipped: {$skipped}");54 return self::SUCCESS;55 }56 57 protected function alreadyNotifiedThisWindow(User $user, Carbon $userLocalNow): bool58 {59 // Example placeholder – implement using a notification_logs table or cache key.60 // Keyed by user + date-hour in their local tz to avoid duplicates within the same hour61 $key = sprintf('notify:%d:%s', $user->id, $userLocalNow->format('Y-m-d-H'));62 return cache()->has($key);63 }64}
Scheduling with Laravel and cron Add this to app/Console/Kernel.php:
1protected function schedule(Schedule $schedule): void2{3 // Run every minute if you want minute-level granularity,4 // or hourly() if your window is exactly at the top of the hour.5 $schedule->command('notifications:send-timezone-digests')->hourly()->withoutOverlapping();6}
Then configure your system cron to run the Laravel scheduler:
1* * * * * php /path/to/artisan schedule:run >> /dev/null 2>&1
That cron runs every minute; the scheduler will trigger the hourly command on the hour.
Avoiding duplicates and overlaps
- withoutOverlapping: Prevents the same task from running twice concurrently (useful if a run takes longer than an hour).
- Dedup by hour: In the command, guard against sending twice within the same hour for a user using a cache or a notification_logs table.
- Queues: Implement ShouldQueue on your notification; configure horizon/queue workers for throughput.
Handling DST and edge cases
- DST jumps: Using Carbon with setTimezone($user->timezone) ensures local hour reflects the correct offset on that day. Some days have 23 or 25 hours; relying on local hour avoids missing or duplicating sends when using “static UTC offsets.”
- Missing/invalid timezones: Default to UTC and log invalid entries. Consider a validation rule using PHP\DateTimeZone::listIdentifiers.
- Users changing preferences mid-day: Your command reads preferences at run-time, so changes apply next hour.
- Minute precision: If you require 9:15 instead of 9:00, store preferred_notification_minute and change the schedule to everyMinute() with a window match on hour+minute.
Optional: database-side prefiltering If your users are huge in number, pre-filter in SQL by computing an approximate UTC hour window per timezone band and then double-check in PHP. Often chunkById + proper indexing is sufficient.
Validation and forms When saving a user’s timezone in your UI, present a select generated from DateTimeZone::listIdentifiers(DateTimeZone::ALL).
Testing tips
- Freeze time with Carbon::setTestNow(Carbon::parse('2025-03-10 13:00:00', 'UTC')).
- Create a few users in different timezones with preferred_notification_hour set and assert that only matching users got a notification (use Notification::fake()).
Example test snippet
1public function test_sends_only_matching_local_hour() 2{ 3 Notification::fake(); 4 5 $ny = User::factory()->create(['timezone' => 'America/New_York', 'preferred_notification_hour' => 9]); 6 $la = User::factory()->create(['timezone' => 'America/Los_Angeles', 'preferred_notification_hour' => 6]); 7 8 Carbon::setTestNow(Carbon::parse('2025-08-25 13:00:00', 'UTC')); // 9am NY, 6am LA 9 10 $this->artisan('notifications:send-timezone-digests')11 ->assertExitCode(Command::SUCCESS);12 13 Notification::assertSentTo($ny, DailyDigestNotification::class);14 Notification::assertSentTo($la, DailyDigestNotification::class);15}
Minute-level scheduling variant If you need 9:15 local time:
1// Migration adds preferred_notification_minute (0-59) 2 3// Command check 4$userLocalNow = Carbon::now('UTC')->setTimezone($user->timezone); 5if ((int)$userLocalNow->format('G') === $user->preferred_notification_hour && 6 (int)$userLocalNow->format('i') === $user->preferred_notification_minute) { 7 // send 8} 9 10// Kernel schedule11$schedule->command('notifications:send-timezone-digests')->everyMinute()->withoutOverlapping();
Production checklist
- Collect valid IANA timezones only; migrate legacy offsets to region names.
- Index users(timezone, preferred_notification_hour) for faster scans if you prefilter.
- Queue notifications and monitor workers (Horizon).
- Add idempotency key (cache or DB log) per user per hour to avoid duplicates.
- Add --dry-run to observe targeting before sending.
This approach is simple to grasp, resilient to DST, and scales as your user base grows.