thomasjohnkane/laravel-scheduled-notifications
thomasjohnkane/laravel-scheduled-notifications lets you schedule Laravel notifications for later delivery with a simple, database-backed API. Delay sends, set recurring schedules, cancel or reschedule pending alerts, and manage queued user notifications from one place.
Snooze (thomasjohnkane/snooze) lets you attach a Laravel notification to a model now and have it delivered later — or repeatedly — without writing your own scheduling table. Release 2.9.1 is purely additive: it adds Laravel 13 support (PR #144, first contribution from @hari-euka). No breaking changes, no deprecations, no config or API changes versus 2.9.0, so the existing 2.x usage patterns below still apply.
Minimal setup:
composer require thomasjohnkane/snooze — confirm your Laravel version resolves against 2.9.1 (Laravel 9/10/11/12/13 as of this release).php artisan vendor:publish for the Snooze provider, then php artisan migrate) so the scheduled_notifications table exists. Publish config/snooze.php at the same time.php artisan list snooze in your installed version, then add it to routes/console.php / Kernel::schedule() at everyMinute() (or ->withoutOverlapping() / ->onOneServer(), see Gotchas).* * * * * php artisan schedule:run), otherwise nothing is ever sent.First use case — delayed delivery from an Eloquent model:
use Thomasjohnkane\Snooze\Traits\Snoozeable; // on User, Order, etc.
$user->snooze(new OrderShipped($order))->sendIn(10); // minutes? check signature
$user->snooze(new TrialEnding($user))->sendAt(now()->addDays(3));
And the recurring case, which is what most teams actually adopt Snooze for:
$user->snooze(new WeeklyDigest($user))->weekly()->until(now()->addMonths(6));
Where to look first in the source: the ScheduledNotification model + migration (that is your data contract), the notification-builder class returned by snooze(), and the console command that performs the dispatch — that command is the piece you must schedule.
Model trait vs. facade. Adding the trait to a notifiable gives you the ergonomic $model->snooze($notification) chain. For targets that aren't Eloquent models (raw email addresses, phone numbers, ad-hoc routes), use the Snooze facade to build the same ScheduledNotification without a morph target. Pick one style per codebase; mixing them makes it hard to reason about what scheduled_notifications.target_* contains.
Builder chain as the scheduling DSL. Treat the returned builder as configuration, not a live object: sendAt() / sendIn() set the first fire time, everyMinute() / hourly() / daily() / weekly() / monthly() / cron() set the repeat interval, and until() bounds it. Always capture the returned ScheduledNotification if you need to show or cancel it later — you cannot easily look it up again by notification class alone once several are queued for the same model.
Cancellation and re-scheduling as first-class operations. The recurring pattern in real apps is: user changes their reminder preference → cancel the pending ScheduledNotification → create a new one. Store the ScheduledNotification id on your own model (a reminder_notification_id column, or a small pivot) rather than querying the package table across morph columns. Wrap "cancel old + create new" in a transaction so a failure doesn't leave the user with two digest jobs.
Notifications stay plain Laravel. Write a normal Notification class with via() and toMail()/toDatabase(). Snooze doesn't wrap or subclass the notification — it serializes and later hands it back to the dispatcher. That means everything you already know about notifications (queues, ShouldQueue, channels, Notifiable routing) works unchanged, and you can reuse the same notification both for immediate $user->notify() and for snoozed delivery.
Pair with events/listeners for delayed workflows. Onboarding, trial expiry, review requests, abandoned-cart nudges and "we haven't heard from you" emails are the sweet spot. Fire-and-forget from a domain event listener: the listener decides the delay, Snooze owns the persistence. This keeps time logic out of your controllers and out of a jobs table that would otherwise grow with one job per pending email.
Testing pattern. In feature tests, freeze time ($this->travelTo(...)), create the snoozed notification, assert a row exists in scheduled_notifications with the expected send_at, then $this->artisan('snooze:...') (or advance past the fire time and invoke the command) and assert with Notification::fake(). Note that Notification::fake() alone will not create snooze rows or be affected by them — assert the DB row for scheduling behaviour, and the fake only for the send. For repeating schedules, travel forward two intervals and run the command twice rather than asserting on internal repeat counters.
sendAt(now()->addSeconds(20)) to fire 20 seconds later.->onOneServer() (requires a shared cache lock) and/or ->withoutOverlapping() on the snooze dispatch command. Two concurrent runs against the same row is the classic "user got the digest twice" bug report.serialize(). Eloquent models are fine (they're serialized by reference), but resources, connections, and closures are not — resolve those inside toMail() at send time.ShouldQueue, the snooze command only enqueues it, so a dead queue worker looks identical to "Snooze is broken." Check the queue before debugging the package. Keeping notifications sync (no ShouldQueue) makes the scheduler command the single point of failure and is easier to reason about for low-volume reminders.send_at is stored against your app timezone; changing config/app.php timezone after rows exist silently shifts everything. Standardise before you ship, and write a test that asserts a known send_at value rather than eyeballing it.->everyMinute() or ->weekly() without ->until() will keep firing forever, including after the user unsubscribes unless you also cancel the row. Give every recurring snooze a cancellation path in your own domain logic (unsubscribe, account deletion, preference change).deleting model hook or a cleanup job, and periodically prune rows where sent_at is set and no repeat remains.config/snooze.php) covers the table name and provider-level wiring; the notification is yours, so custom routing, channels and payload shaping belong in the notification class, not in the package. If you need multi-tenant scoping or audit trails, add columns to your own tables and keep scheduled_notifications as a thin scheduling ledger you query read-only.^2.9) and running your notification-serialization tests after any Laravel major bump, since the framework's notification serialization is what actually round-trips through those rows.How can I help you explore Laravel packages today?