laravel-cron-mailer maintained by bagoespantera
Laravel Cron Mailer
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.