bagoespantera / laravel-cron-mailer
Queue e-mails into the database and deliver them from a scheduled console worker.
Package info
github.com/BagoesPantera/laravel-cron-mailer
pkg:composer/bagoespantera/laravel-cron-mailer
Requires
- php: ^8.2
- illuminate/database: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/mail: ^10.0 || ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^10.0 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- orchestra/testbench: ^8.0 || ^9.0 || ^10.0
- phpunit/phpunit: ^10.0 || ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A database-driven outbox mailer for Laravel without queue workers. Send asynchronous e-mails seamlessly via Laravel Scheduler (Cron).
Key Features
- Zero Queue Worker Setup — No Redis, no Supervisor, no
queue:workdaemon required. E-mails are stored in your existing database and delivered straight from the scheduler. - Non-Blocking Enqueueing — The global
queueMail()helper writes to your DB inside a try-catch, reports failures via Laravel'sreport(), and never bubbles an exception up to the HTTP request. - Auto-Serialization via Reflection & Public Property Hydration — Every public property of your mailable is captured. Eloquent models are stored as
[class, primary key]pairs and reloaded with fresh data at send time viafindOrFail(). Supports both constructor property promotion and free-form public properties. - Automatic Retry & Per-Row Isolation — A failure for one recipient increments its
attemptscounter, captures the exception message, and moves on. A broken row never blocks the rest of the batch. - Laravel 10 – 13 Compatibility — A single package version runs on Laravel 10, 11, 12, and 13 thanks to a wide
illuminate/*constraint matrix.
Requirements
| Dependency | Constraint |
|---|---|
| PHP | ^8.2 |
| Laravel | ^10.0 || ^11.0 || ^12.0 || ^13.0 |
| Database | Any PDO driver supported by Laravel (mysql, pgsql, sqlite, sqlsrv) |
Installation
Step 1 — Install via Composer
composer require bagoespantera/laravel-cron-mailer
The CronMailerServiceProvider is registered automatically through Laravel's package discovery.
Step 2 — Publish Configuration & Migration
php artisan vendor:publish --provider="BagoesPantera\CronMailer\CronMailerServiceProvider"
This copies:
config/cron-mailer.php— tunable knobs for the outboxdatabase/migrations/*_create_pending_emails_table.php— the storage table
Both publish tags (
cron-mailer-config,cron-mailer-migrations) are also available if you need to publish them separately.
Step 3 — Run the Migration
php artisan migrate
This creates the outbox table. Its name follows the value of cron-mailer.table_name in your config (see below).
Configuration
All configuration lives in config/cron-mailer.php. After publishing the file, the following options are available:
| Key | Default | Description |
|---|---|---|
table_name |
'pending_emails' |
Database table used as the outbox. The migration reads this key dynamically, so rename it freely before running migrate. |
max_attempts |
3 |
Hard cap on delivery attempts. Once a row's attempts column reaches this value the worker skips it permanently. |
batch_size |
10 |
Maximum number of rows pulled from the outbox on every worker run. Keep it small enough to stay inside the scheduler's window. |
delete_after_send |
true |
What happens to a row after a successful delivery. true deletes it; false retains it as an audit trail (see below). |
Delivery Audit Trail (delete_after_send)
By default a successfully delivered e-mail is deleted from the outbox, so the table only ever holds work that still needs doing. Set delete_after_send to false if you need to keep a record of what was sent:
// config/cron-mailer.php 'delete_after_send' => false,
With that setting the worker marks the row instead of removing it:
| Column | Value after a successful send |
|---|---|
status |
sent |
sent_at |
timestamp of the successful delivery |
error_message |
null (cleared, even on a successful retry) |
attempts |
left untouched, so you keep the retry count |
-- what your outbox looks like afterwards SELECT recipient_email, status, attempts, sent_at FROM pending_emails;
A few things worth knowing:
sentrows are never re-processed. The worker only selectspendingandfailedrows, so retained history is inert and safe to leave in place.- You own the cleanup. With
delete_after_send => falsethe table grows forever unless you prune it. A scheduledDB::table('pending_emails')->where('status', 'sent')->where('sent_at', '<', now()->subDays(30))->delete();is a common companion. - Failed rows are unaffected — they stay in the table with
status = 'failed'regardless of this setting, so retries keep working either way.
Usage Guide
Enqueuing E-mails
The package exposes a single global helper — queueMail(Mailable $mailable, string|array $recipients): bool.
use App\Mail\OrderShipped; $stored = queueMail(new OrderShipped($order), 'john.doe@example.com'); if (! $stored) { // write path when DB insert fails; the exception was already `report()`ed return back()->with('warning', 'Your receipt will be sent in a minute.'); }
Passing an array of addresses enqueues one row per recipient in a single batch insert:
queueMail(new NewsletterDigest(), [ 'john.doe@example.com', 'jane.doe@example.com', 'ops@company.com', ]);
queueMail() returns true on success and false on failure. Failures are routed to your configured exception logger via Laravel's report() helper — they never interrupt the request cycle.
Mailable Class Requirements
Any class extending Illuminate\Mail\Mailable works. The outbox captures every public property, in either style:
// Style 1 — Constructor Property Promotion namespace App\Mail; use App\Models\Order; use App\Models\User; use Illuminate\Mail\Mailable; class OrderShipped extends Mailable { public function __construct( public Order $order, public User $customer, public string $courier = 'dhl', ) {} public function build(): self { return $this->subject('Order #'.$this->order->id.' shipped') ->view('mail.orders.shipped'); } }
// Style 2 — Public properties assigned after instantiation class WelcomeBack extends Mailable { public User $user; public string $headline = 'Welcome back'; public function build(): self { return $this->subject($this->headline) ->view('mail.welcome-back'); } } // Caller: $mail = new WelcomeBack(); $mail->user = $user; $mail->headline = 'Long time no see!'; queueMail($mail, $user->email);
Eloquent model values are reloaded via findOrFail() at delivery time — so stale data from the enqueue moment is never used. Scalars, arrays, and plain objects are stored as-is.
Running the Worker
One-off / Debug
php artisan cron-mail:process
Optional override for the batch size:
php artisan cron-mail:process --limit=20
The command prints a summary of how many mails were sent vs. failed.
Scheduled via Laravel Scheduler
Register it in routes/console.php (or app/Console/Kernel.php for older Laravel):
use Illuminate\Support\Facades\Schedule; Schedule::command('cron-mail:process') ->everyMinute() ->withoutOverlapping();
withoutOverlapping() is highly recommended: if one run bleeds into the next minute, Laravel will simply skip the overlap.
Worker Selection Criteria
Each run selects rows matching:
status = 'pending'OR(status = 'failed' AND attempts < max_attempts), ordered bycreated_at ASC, limited tobatch_size(or--limit).
Rows already marked sent (see delete_after_send) are never matched.
The composite index ['status', 'attempts', 'created_at'] in the migration keeps this query cheap even on very large outboxes.
Testing
The package ships with an isolated PHPUnit suite driven by Orchestra Testbench. It runs against an in-memory SQLite database by default — never against your application's primary DB.
From the package root (working on the package itself):
composer install --dev ./vendor/bin/phpunit
Expected output:
OK (14 tests, 52 assertions)
The suite covers:
- single-recipient & multi-recipient enqueue
- correct payload shape for Eloquent models vs. scalars
- fail-safe behaviour when the outbox table is missing
- reconstruction + send + row removal end-to-end
- public-property hydration (verified both present and absent)
- failure marking (
attempts++,error_messagepopulated,status = 'failed') max_attemptsgating- retry below
max_attempts batch_sizeenforcement- audit-trail retention with
delete_after_send => false(status = 'sent',sent_atwritten,error_messagecleared) - sent rows are never picked up again on subsequent runs
- default
delete_after_send => truestill deletes
License
The Laravel Cron Mailer package is open-sourced software licensed under the MIT License.