Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Scheduled Notifications Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. composer require thomasjohnkane/snooze — confirm your Laravel version resolves against 2.9.1 (Laravel 9/10/11/12/13 as of this release).
  2. Publish and run the package migration (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.
  3. Register the dispatcher in the scheduler. The package ships an artisan command that scans for due rows and fires the notifications — look for it first with php artisan list snooze in your installed version, then add it to routes/console.php / Kernel::schedule() at everyMinute() (or ->withoutOverlapping() / ->onOneServer(), see Gotchas).
  4. Make sure the normal Laravel scheduler cron is running (* * * * * 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.

Implementation Patterns

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.

Gotchas and Tips

  • One-minute resolution. The dispatcher is a scheduler command, so effective delivery granularity is a minute, and it is only as reliable as your cron. Don't use Snooze where you need second-level precision; also don't expect sendAt(now()->addSeconds(20)) to fire 20 seconds later.
  • Schedule the command defensively. If you run more than one app server or multiple scheduler entries, use ->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.
  • Serialized notifications are a schema. Pending rows hold a serialized copy of the notification object. Refactoring a notification's constructor (renaming a property, changing promoted parameter order, removing a public property) will break already scheduled rows and surface as unserialize errors at dispatch time. When you change a snoozed notification class, ship a cleanup/migration for outstanding rows, or version the class name.
  • No closures, no unserializable state. Anything captured in the notification must survive serialize(). Eloquent models are fine (they're serialized by reference), but resources, connections, and closures are not — resolve those inside toMail() at send time.
  • Queued vs. sync. If the notification implements 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.
  • Timezone. 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.
  • Repeats are unbounded by default. An ->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 the notifiable. Rows reference the target via a morph relation; hard-deleting the user leaves orphan rows that the dispatcher may still try to send. Cascade-delete snoozed notifications in a deleting model hook or a cleanup job, and periodically prune rows where sent_at is set and no repeat remains.
  • Indexes matter at scale. The dispatcher queries on the fire-time column every minute. If you're scheduling thousands of reminders, verify the package migration's index on the send-time column (and the sent/repeat columns) actually exists in your production schema — teams that hand-rolled the migration often drop it.
  • Extension points, not forks. Config (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.
  • Upgrading. 2.9.1's only change is Laravel 13 support — a drop-in for 2.9.0 with no migration or config diff. It is still worth pinning (^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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
3mdikram/worldmoneywords
snowsoft/config
itmosoft/laravel-subscriptions
larva/laravel-ai-qwen
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib