codeat3/laravel-log-emails
Laravel package that logs outgoing emails instead of sending them, making it useful for local development and testing. Capture messages to inspect recipients, subjects, and content without configuring a real mail service or risking accidental delivery.
This is a new package (first tagged release, 1.0.0), so there is no migration path to worry about — treat this as a greenfield evaluation rather than an upgrade.
Minimal setup:
composer require the package (dev-only in most projects — see Gotchas).php artisan vendor:publish --provider="...\MailDumpServiceProvider" --tag="migrations"
php artisan migrate
MAIL_MAILER at, or (b) listen to Laravel's MessageSending/MessageSent events and persist a copy.Mail facade and confirm a row appears in the dump table.Where to look first:
created_at and recipient columns.enabled flag or environment allow-list.First use case: local/staging mail inspection. Swap your local mailer to the dump driver, click through a registration or password-reset flow, and read the rendered HTML out of the database instead of hunting through storage/logs/laravel.log or a Mailpit/Mailhog container. It is a zero-infrastructure alternative to a local SMTP catcher, which is genuinely useful on locked-down machines, in CI, or on shared staging boxes where you can't open extra ports.
Environment-scoped activation. The safest pattern is to enable the dump only outside production:
// config/mail-dump.php (name per package)
'enabled' => env('MAIL_DUMP_ENABLED', app()->environment('local', 'staging')),
Keep MAIL_DUMP_ENABLED=false in .env.example for production so nobody accidentally persists customer email bodies into a database that gets backed up and replicated.
Dump-and-still-send. If the package works as an event listener rather than a transport, you get both behaviours for free: real delivery plus a persisted copy. That is the better pattern for staging — you can verify actual deliverability and still inspect the exact payload. If it replaces the mailer, you lose delivery, so don't point staging at it unless you only care about rendering.
Assertion helper in tests. A useful pattern is a thin wrapper in your base TestCase:
protected function assertMailDumped(string $mailable, ?callable $callback = null): void
{
$mail = MailDump::query()
->where('subject', (new $mailable)->envelope()->subject)
->latest('id')
->firstOrFail();
$callback?->__invoke($mail);
$this->assertNotNull($mail->html_body);
}
Be deliberate about which you use: Mail::fake() proves your application dispatched a mailable with the right data; a mail dump proves the mailable rendered correctly. They test different layers, and the dump is the only way to catch a Blade view that throws inside render() — something Mail::fake() happily ignores. Use both: Mail::fake() for dispatch assertions, one or two dump-based smoke tests for rendering.
Debug endpoint / Nova or Filament resource. Since the payload is in the database, expose it. A local-only route with an abort_unless(app()->isLocal(), 404) guard, or a read-only Filament resource, turns the package from "query the DB by hand" into something the whole team will use:
Route::get('/mail-dump/{mailDump}', fn (MailDump $mailDump) => response($mailDump->html_body))
->middleware(['web', 'can:view-mail-dump']);
Retention. Wire pruning into the scheduler from day one:
$schedule->command('model:prune')->daily();
and add Prunable to the dump model if it doesn't already ship with it, keeping 7–30 days.
CI. Point the test environment's mailer at the dump and assert against stored rows — this removes the need for Mailpit/Mailhog as a service container in your test job.
Attachment size is the real cost. Email attachments are base64-inflated before storage — a 5 MB PDF becomes ~6.7 MB in the row (and more again if the column is TEXT with encoding overhead). One invoice-heavy test suite can add gigabytes. Decide up front: strip attachments via config, or store only metadata (filename, mime, size) and skip the blob.
MySQL TEXT truncation. TEXT caps at 64 KB; a large HTML email with inline CSS will silently truncate or throw depending on strict mode. Use MEDIUMTEXT/LONGTEXT (or json/longText in Postgres) for body columns. Check the shipped migration — this is the most common defect in v1 packages of this kind.
Queued mail and serialization. If the package persists on MessageSending, note that with ShouldQueue mailables the mailable is serialized before sending. Eloquent models or closures captured as public properties may not survive the round trip, and the dump may show a different view state than you expect. Test with QUEUE_CONNECTION=sync when diagnosing.
Idempotency of migrations. First releases often ship migrations without Schema::hasTable guards, or publish the same filename twice if you re-run vendor:publish. Check for a dated migration name and avoid re-publishing after the package bumps to 1.0.1 — you'll get a duplicate-table error, or worse, a silently diverged schema.
Production leakage is the top risk. Email bodies routinely contain password-reset tokens, magic sign-in links, PII, and sometimes full payment details from a receipt. A dump table in a shared staging DB is a compliance problem, not just an inconvenience. Gate it on environment, and if you must have it in staging, add encryption at rest or a column-level cast on the body.
Ordering and concurrency. Use the auto-increment id, not created_at, for "latest email" lookups — several emails can land in the same second, especially in tests, and second-precision timestamps will make assertions flaky.
Missing indexes. Filtering by recipient is the most common query, but to is often stored as JSON or a comma-joined string, which defeats a plain index. If you query it regularly, add a generated/indexed recipient column rather than scanning.
Extension points to confirm against the README. Check whether the package exposes a configurable model (so you can subclass and add scopes/casts) and a configurable table name. If the model is hard-coded and final, you'll be fighting it — that's worth raising upstream before adopting widely. Similarly, confirm whether headers are stored as JSON (easy) or a raw string (requires manual parsing to read Message-ID or X-* headers).
Debugging "nothing is stored". In order: is the config enabled for this environment; is the registered mailer actually the one being used (check config('mail.default') and any per-mailer overrides in the mailable's mailer property); is the queue worker running the same config as the web process (stale config:cache is the usual culprit); and does the event listener fire before an exception in sending aborts the pipeline.
Version caution. At 1.0.0 there's no track record on breaking changes. Pin the constraint to ^1.0 and be prepared to read the diff on minor bumps — schema changes in a package this young are likely, and they will surface as migration conflicts rather than clear upgrade errors.
How can I help you explore Laravel packages today?