oneduo/laravel-mail-scheduler
Schedule outgoing Laravel mail with a simple, database-backed approach: store messages with send times, dispatch them via Laravel’s scheduler and queue, and manage pending deliveries through an easy Eloquent API. Useful for reminders, digests, and delayed notifications.
This package adds database-backed scheduled delivery of mailables on top of Laravel's built-in later()/delay(): a mailable is persisted as a row with a send time, and a console command flushes due rows. Use it when you need visibility and cancellation of future emails (drip sequences, reminders, "send in 3 days unless they upgrade") rather than a fire-and-forget queue delay.
Minimum path:
composer require oneduo/laravel-mail-scheduler — v1.0.0 adds the Laravel 11 constraint. If you're on Laravel 12, check composer.json constraints before assuming it installs.php artisan vendor:publish --tag="mail-scheduler-migrations" then php artisan migrate. Read the migration first — it tells you the exact table/column names you'll query in tests and reports.use Schedulable; in the Mailable class), then schedule an instance instead of calling Mail::send().routes/console.php (Schedule::command('...')->everyMinute()->withoutOverlapping()), not Kernel.php. This step is the one people forget; without it nothing ever sends.schedule:run exists and a queue worker is running (scheduled mail is normally pushed onto the queue, not sent inline).First use case: in a Registered listener, schedule the day-3 onboarding email rather than building a ShouldQueue job + delay. You get a row you can inspect, re-schedule, or cancel from an admin panel.
Before wiring anything into production, read the trait source (src/Traits/Schedulable.php) and the command class — the public surface is small (schedule() / scheduleTo() plus the command), and the README is the authoritative place for exact signatures, which shift between minor versions.
Schedule from domain events, not controllers. Keep the mailable "dumb" (it just uses the trait) and do the scheduling in a listener or an action class. This keeps the scheduling decision testable and lets you cancel from the same place later.
Treat the scheduled row as a first-class record. The package exposes a model (swappable via config). Resolve it through its repository/model rather than raw DB queries so a later config change (custom model, custom table) doesn't break your admin screens. Typical operations: list pending for a user, cancel one, re-schedule one to a new at.
Use it as the persisted alternative to delay(). Decision rule:
Mail::to($u)->later(...) / $mailable->delay(...) — short, non-business-critical delays where losing the email on a queue flush is acceptable.Mailable::afterCommit() and ShouldQueue still apply; combine, don't replace.Recipient overrides. Use the scheduleTo()-style entry point when the recipient differs from the mailable's constructor data (e.g. CC an account owner later). Otherwise the trait inherits the standard to()/cc() set on the mailable and serialises them into the row.
Custom model / multi-tenant setups. Publish the config and point the model at your own subclass, then apply a global scope (tenant, team) — the scheduler command runs in the console with no authenticated user, so any tenancy scoping must be explicit on the model or the command will happily send across tenants.
Testing. Mail::fake() alone does not prove scheduling works. Assert two things: (a) after triggering the listener, a scheduled-mail row exists with the expected at and recipient; (b) after $this->travelTo(...) past the send time and running the package command via $this->artisan('...'), Mail::assertQueued(...) holds. Freeze time rather than sleeping; the command is minute-granular, so tests must move the clock.
Observability. Since scheduled mail is business state, wrap creation in a small service that fires your own domain event (MailScheduled, MailScheduledCancelled) so analytics and audit logs don't depend on the package's internals.
composer why-not oneduo/laravel-mail-scheduler before forcing the install; do not --ignore-platform-reqs in production.->withoutOverlapping() (and, on multiple hosts, ->onOneServer() for a cache-shared scheduler) two hosts can pick the same due row. Add both, and confirm whether the package locks rows internally before relying on it.schedule:run not running, or the queue worker being down, silently means "nothing sent" — the rows just accumulate. Alert on the age of the oldest unsent row, not on error logs.travelTo, Carbon::setTestNow and any comparison you write must be consistent. Assert on the stored value, not a formatted string.ShouldBeUnique-style guards over stale snapshots, or re-fetch in prepareMailableForDelivery().sent_mails table) before someone asks "was this email actually sent?".Mail::fake() gives false confidence. The command fetches models directly; a fake will pass while the command throws. Always add the command-level integration test described above.SoftDeletes/deleted guard in the mailable or a model observer that cancels pending rows — otherwise scheduled drip emails go to churned accounts.scheduled_at, but avoid overriding the command — reimplementing the flush loop forfeits future fixes in a package that just declared 1.0 stability.How can I help you explore Laravel packages today?