dcblogdev/laravel-sent-emails
Laravel package that captures every email your app sends, storing them in the database or filesystem so you can browse, inspect, and resend them later. Includes a built-in UI and configurable options for debugging mail issues or keeping an audit trail in production.
dcblogdev/laravel-sent-emails is a database-backed audit trail for outbound mail. It hooks Laravel's MessageSent event, writes each message (from/to/cc/bcc/subject/headers/body) to a sent_emails table, and ships a small browsable UI so you can read what your app actually sent — without digging through a mail provider's dashboard.
Minimal setup:
composer require dcblogdev/laravel-sent-emails
php artisan vendor:publish --provider="Dcblogdev\SentEmails\SentEmailsServiceProvider"
php artisan migrate
Look at these first, in order:
config/sentemails.php — this is where the package actually lives. Decide the toggle that controls whether mail is recorded at all, the route prefix, and the middleware stack guarding the UI. Get this right before you ship, not after./sent-emails — the index route. Hit it after sending a mail.First use case: send something from tinker and confirm it lands.
Mail::raw('hello', fn ($m) => $m->to('[email protected]')->subject('test'));
Then open /sent-emails. If the row is there, the wiring is done. If it isn't, you have a listener/event problem, not a package problem — go straight to Gotchas.
Version note: v2.0.8 is a compatibility-only release — it adds Laravel 12 support (PR #29 from @laravel-shift). There are no API, config, or schema changes versus v2.0.7. If you're on Laravel 12, bump; if you're on 10/11, bumping is a no-op but harmless.
Capture is automatic — resist writing your own listener. The service provider registers everything. Do not wrap Mail::send() in a logging helper, and do not add a MessageSent listener of your own to duplicate the record. The main integration work is policy, not code: where it's enabled, who can see it, and how long you keep it.
Gate it by environment. The most common real-world pattern is: enabled everywhere except production, or enabled in production but with the UI locked down. Drive the enable flag from an env var rather than editing config per environment:
// config/sentemails.php
'storeEmails' => env('SENT_EMAILS_ENABLED', true),
Keeping it on in production is legitimate — it's an audit trail for compliance and a forensic tool when a customer says "I never got that email." Just pair it with retention.
Treat the UI as an internal admin surface. The default middleware is web + auth, which is a floor, not a ceiling. In any app with more than one kind of user, layer an authorization gate on top, either through the config middleware array or by wrapping the routes:
'middleware' => ['web', 'auth', 'can:viewSentEmails'],
Or override the middleware from AppServiceProvider / bootstrap/app.php if your policy lives there. Also change the route prefix or put the routes behind a subdomain if /sent-emails is discoverable by authenticated non-admins.
Query the table directly for reporting. The package's browsing UI is for humans; the table is the API. For "did this user's welcome email go out", "what did we send to this domain last week", or "show me every password reset for this account", write Eloquent queries against the model rather than scraping the UI. Add your own read-only controller or an artisan command for the common cases — those become the day-to-day tool, and the package UI becomes the escape hatch.
Retention is your job. There is no pruning in the package. The standard pattern is a scheduled command:
// app/Console/Commands/PruneSentEmails.php
Model::where('created_at', '<', now()->subDays(30))->delete();
Register it in routes/console.php or the scheduler. Size the window against your mail volume — a busy transactional app can produce gigabytes of HTML bodies per month.
Testing pattern. The table is a convenient assertion target for "did the application attempt to send this?" in feature tests that use the real mailer (e.g. with the array or log transport). But for the majority of tests, Mail::fake() is still the right tool — see the gotcha below about the two not mixing.
Queued mail works, with a caveat. Nothing special is needed: the MessageSent event fires in whatever process actually dispatches the message, which for queued mail is the worker. Make sure the worker has the same config (env var present, migrations run against the same database). If you run workers in a separate container with a stripped-down config cache, that's where capture silently disappears.
Mail::fake() writes no rows. The fake swaps out the mailer entirely, so MessageSent never fires and the package records nothing. If you've written tests asserting assertDatabaseHas('sent_emails', ...) alongside Mail::fake(), they will pass vacuously or fail confusingly. Pick one per test.
Failed sends are invisible. Only successfully dispatched messages fire MessageSent. If SMTP rejects the message or the transport throws, there is no row — which makes the table a record of what left the building, not what you tried to send. Don't use it to debug transport failures; use your logs and failed_jobs for that.
Mail that bypasses Laravel's mailer is not captured. Direct API calls to Postmark/Mailgun/SES, or mail sent from a separate service, never touch MessageSent. If you're migrating to a provider SDK, your audit trail quietly goes dark. Watch for this if you introduce a new sending path.
Storage bloat is the number one operational issue. Bodies are full HTML, headers are full MIME headers, and the whole thing is duplicated from your mail provider's copy. Two mitigations: shorten retention aggressively, and consider whether you need the body at all. If your config exposes a body-storage toggle, turn it off in high-volume environments and keep only metadata — subject, recipients, timestamp is enough for 90% of "did it send" questions.
Attachments. If attachment persistence is enabled, base64 content can land in the database and blow up row sizes fast. Check the config before enabling it in an app that sends invoices or PDFs. Most teams leave it off and rely on the provider's copy.
This table contains live secrets. Password reset links, magic login tokens, invitation URLs, and PII all pass through body. Anyone with UI access can take over any account by grabbing a reset link. This is the single strongest argument for locking down the middleware and for retention limits. Consider whether a compliance requirement actually demands body storage, or just metadata.
Unstyled UI? The views depend on the AdminTw/Blade asset stack. If the page renders as bare HTML, you haven't published or built the front-end assets — check the package README's asset step rather than assuming the install is broken.
Routes missing after install? Clear the caches. Laravel 11/12's bootstrap/app.php routing setup plus a cached route file is the usual culprit:
php artisan route:list | grep sent
php artisan optimize:clear
Multi-connection setups. The model uses the default DB connection unless configured otherwise. If your app uses a separate connection for logging/audit data, or per-tenant connections, verify where rows actually land — otherwise you'll be looking at an empty table in the right database.
Laravel 12 upgrade path. v2.0.8 only widens the illuminate/* constraints. No migration, no config republish, no view changes. If you're doing a Laravel 11 → 12 upgrade, bump this package in the same pass and you're done; it will not be the thing that breaks.
Extension point worth knowing: the views and config are publishable, so if you need a custom column, an extra filter, or a "resend" button, fork the published views rather than patching vendor/. Anything you edit in vendor/ is lost on the next composer update — and given the release cadence here, that will be soon.
How can I help you explore Laravel packages today?