backstage/laravel-mails
Log, preview, and manage every outgoing Laravel email. This lightweight package stores messages in the database so you can inspect rendered HTML, review recipients and subjects, resend mails, and debug delivery issues in development or production—no more digging through server logs or mail provider...
Install and wire it up (5 minutes):
composer require backstage/laravel-mails
php artisan vendor:publish --provider="Backstage\LaravelMails\LaravelMailsServiceProvider"
php artisan migrate
If composer require fails to resolve, check whether spatie/laravel-ray (or anything pulling it in) is already installed — older 3.0.x releases conflicted with it. Pin ^3.0.14 or later, which removes that conflict (PR #87). Note the gap in the release history: 3.0.12 and 3.0.13 were never tagged, so don't be alarmed if your lockfile jumps 3.0.11 → 3.0.14 in one step.
Then check config/laravel-mails.php — this is where the package decides what it records (metadata only vs. full body/headers/attachments) and where (default DB connection, table name). Look here first, because the default is usually the most conservative setting, and the first thing you'll want is more detail than the default gives you.
Where to look first, in order:
MailLog-style model) — this is your extension point.First real use case: "A customer says they never got the password reset email." Before installing, your answer is "check the mail provider's logs." After installing, it's:
$mail = MailLog::where('to', 'like', '%[email protected]%')
->latest()
->first();
$mail?->subject; // did we even try to send it?
$mail?->status; // sent, failed, queued?
$mail?->created_at; // when?
That single query — answering "did our app attempt this mail, to this address, with this subject, at this time?" — is the 80% use case and the reason to install the package at all. Get that working before you build anything on top of it.
Sanity check immediately: send yourself a mail through the normal path (Mail::to(...)->send(...), plus one queued mail), and confirm two rows land. If the queued one doesn't, your queue worker isn't running the listener — fix that before you trust the log.
1. Treat the log as an audit trail, not a mail client. The pattern that ages well is append-only metadata: recipient, subject, mailable class, status, timestamps, and a correlation ID. Store bodies only when you have a concrete reason (e.g. regulated communications), because body storage is what turns this table from 50 MB into 50 GB.
2. Attach application context at send time. The single highest-value customization is getting who and what onto the record. Two workable approaches:
// Option A: headers on the mailable — portable, survives across drivers
$mailable->withSymfonyMessage(function (Email $message) use ($order) {
$message->getHeaders()->addTextHeader('X-App-Context', "order:{$order->id}");
});
// Option B: capture the authenticated user / tenancy at the moment of send
// via a resolver or model hook, e.g. stamping user_id + tenant_id
Option A keeps the information in the mail itself (so it's visible in Mailpit and your provider too); Option B makes it queryable with an index. Do both if you're multi-tenant — you'll want tenant_id as a real indexed column, not a header you have to parse.
3. Build the support view as a scoped query, not a dashboard. Resist the urge to ship a full UI. A single read-only route behind can:view-mail-logs that accepts ?email= or ?user= and renders the last 20 rows pays for itself immediately and is trivial to secure. Anything fancier is a product decision, not an infrastructure one.
4. Prune on a schedule from day one.
// routes/console.php or a scheduled command
Schedule::command('model:prune', ['--model' => [MailLog::class]])->daily();
Configure prunable() on the model and set a retention window that matches your compliance posture (30–90 days is typical for bodies; metadata can live longer). Adding pruning after the table hits a million rows is a far worse afternoon.
5. Assert on it in tests. Once mail logging is on, it becomes a cheap observability assertion in feature tests — beyond Mail::assertSent(), you can assert the record exists with the right context attached. Keep these assertions focused on context (did the right user/tenant/order get stamped?) rather than re-testing Laravel's mail fake.
6. Correlate with your error tracking. When a send throws, the exception path and the log row should share an identifier (mailable class + recipient + timestamp is usually enough). That lets you go from a Sentry event straight to "and here's the mail row with status=failed."
7. Resend tooling — the second-order win. Once you have to, subject, and the mailable class, a small artisan command or admin action that re-dispatches a stored mailable is often what people actually wanted when they installed the package. It's a natural extension point, but only build it if you control the mailable's constructor cleanly — see gotchas.
Storage growth is the failure mode, and it's silent. Tables like this fail on volume, not on correctness. Check row count and table size weekly for the first month (SELECT COUNT(*), pg_total_relation_size(...) or the MySQL equivalent). If bodies and attachments are being stored, you're accumulating base64 blobs in the database — confirm the config actually does what you assume before you enable it.
PII lives here now. This table contains recipient addresses, subjects, and possibly full message bodies. That means: restrict the dashboard route, exclude the table from any DB dump you hand to a third party, and think about whether it belongs in your GDPR data-export/erasure flow. A "mail log" is very often personal data.
Queue semantics will confuse you. Records are typically written when the mail is handed off to the mailer, not when the provider confirms delivery. So status = sent may mean "queued for the provider," not "delivered." If you need delivery confirmation, that comes from webhooks (SES/Sendgrid/Postmark), not from this package — don't over-read the status column.
Failed sends may or may not be captured. If a mailable throws during build() before the mailer is invoked, there's no message to log. Don't assume the absence of a row means "never attempted" unless you've verified the capture point; a failed row is more trustworthy than a missing one.
php artisan config:cache will bite you. Packages with env-driven config (MAIL_LOG_ENABLED=true) read env at config-load time. Toggle it in config/laravel-mails.php or remember to re-run config:cache after any .env change in a deployed environment. Symptom: "it works locally, logs nothing in staging."
Local dev interaction with Mailpit/Log driver. If you're logging via an event listener rather than a mail transport, MAIL_MAILER=log and Mailpit setups both still fire the events — good, your local logs match production shape. If the package hooks a transport instead, local behavior diverges and you'll chase phantom bugs. Determine which mechanism it uses early; it determines whether your dev and prod data are comparable.
Dependency conflicts are this package's most likely install failure. v3.0.14 removed the spatie/laravel-ray conflict, which means installs that previously failed now succeed. Practical upshot: if you tried this package on an earlier 3.0.x and gave up, retry on ^3.0.14 before writing it off — and conversely, if you were pinned to 3.0.11, upgrading is safe and dependency-only, with no migration or config changes to chase.
Extension points to reach for before forking: custom model (subclass + rebind in the service provider), the published config for capture rules, and any header/resolver hook. Given this is a small package (255 stars, zero known dependents), assume you will eventually need to patch or wrap it — so keep your customizations in your app (a subclassed model, a service provider binding, a withSymfonyMessage hook) rather than editing vendor code. Write down the package version you pinned to, and check the release history before upgrading; with a thin maintainer base, a breaking change can arrive with a minor version bump, and the 3.0.11 → 3.0.14 jump shows tags can be skipped without announcement.
Verify before you depend on it. Two things to confirm in your own environment in the first ten minutes: (1) a queued mailable produces a row when the worker actually processes it, and (2) composer update doesn't move the package to a version whose migration conflicts with yours. Given the low dependent count, treat this as a useful, replaceable observability layer — keep the query surface behind a small repository or scope so you can swap it for a hand-rolled listener if maintenance stalls.
How can I help you explore Laravel packages today?