themsaid/laravel-mail-preview
Laravel Mail Preview captures outgoing emails and lets you inspect them in the browser at a dedicated route. It stores sent messages for easy debugging, so you can review HTML and plain-text content during development without digging through logs or real inboxes.
The package registers a custom mail transport called preview. Instead of sending, it writes each message to disk as an HTML file and exposes a small browser UI to inspect them — perfect for iterating on transactional mail locally without an SMTP inbox or a real recipient.
Minimal setup:
composer require spatie/laravel-mail-preview
php artisan vendor:publish --provider="Spatie\MailPreview\MailPreviewServiceProvider"
Register a dedicated mailer in config/mail.php (Laravel 9+ shape):
'mailers' => [
// ...
'preview' => [
'transport' => 'preview',
],
],
Point your local environment at it and clear cached config:
MAIL_MAILER=preview
php artisan config:clear
Trigger any mailable — a route, a Tinker call, a seeded registration flow — then open the preview route (default /mail-preview). You'll see an index of captured emails; clicking one renders the HTML exactly as it would be sent.
Where to look first:
storage/framework/emails/ (or whatever preview_path resolves to) — the raw HTML files are there if the UI ever misbehaves.First real use case: you're building a welcome/reset-password/receipt email and need to check layout, subject, and variables without spamming yourself or wiring up Mailtrap. Send it once, refresh the preview route, iterate on the Blade view.
Dedicated mailer, not a global override. Keep the preview mailer as a separate entry in config/mail.php and switch via MAIL_MAILER. This lets teammates keep log or smtp locally while you opt in, and avoids accidentally committing a .env change that breaks CI.
Preview a specific mailable on demand. You don't have to flip the whole app:
Mail::mailer('preview')->to('[email protected]')->send(new OrderShipped($order));
Useful in a Tinker session or a throwaway route when you only want to check one template.
Batch render for design review. A tiny artisan command that loops over every mailable with fixture data gives you a full set of previews in one pass — ideal before a release or when a designer wants to review all templates at once:
foreach ([new WelcomeMail($user), new ReceiptMail($order)] as $mailable) {
Mail::mailer('preview')->to('[email protected]')->send($mailable);
}
Because 6.2.3 added a Unix timestamp (U) to the default file-name date format, a tight loop that renders several mailables within the same second no longer overwrites earlier previews. On older versions, add U to preview_date_format yourself if you're batching.
Cover notifications too. Anything routed through the mail channel — MailMessage notifications, password resets, verification emails — uses the same transport, so the preview route doubles as a notification inspector. This matters more than it sounds: a notification fanned out to several recipients sends several messages in the same request, which is exactly the case the 6.2.3 timestamp fix protects.
Keep assertions separate. Mail::fake() is still the right tool for tests. Don't try to assert against the preview driver's files; use the fake for behavior and the preview driver for eyeballing. Mixing the two leads to brittle tests coupled to storage paths.
Environment gating. Register the preview route only where it belongs. Most teams wrap the service provider's route registration in an app()->environment('local') check, or put the preview mailer behind MAIL_MAILER=preview locally and never reference it in staging/production config. If you do expose it on a shared dev server, put auth middleware in front of the route.
Queue-aware workflow. If mail is queued (the default for many Mailables), the preview file only appears after the worker processes the job. Run php artisan queue:work alongside your dev server, or set QUEUE_CONNECTION=sync locally so previews land immediately — a common source of "the package is broken" confusion.
Housekeeping. Preview files accumulate silently. Add a scheduled prune or simply rm -rf the directory in your dev reset script, and make sure the storage path is gitignored. Unique timestamps mean the directory grows faster than it used to — no collisions, but no overwrites either.
MAIL_MAILER or the mailer block, run php artisan config:clear. Similarly, long-running queue workers cache config at boot — restart them after any change.preview_date_format: the 6.2.3 fix only lands in the package's own default config. If you published the config file (or copied an older snippet), the U token is missing and same-second previews still overwrite each other. Add U to your format string, or republish with --force and re-apply your customizations. Same reasoning applies to any future default change — diff your published config after each minor bump.MAIL_MAILER=preview without adding the preview mailer to config/mail.php yields an "unsupported mailer" exception. The env var alone isn't enough on modern Laravel./mail-preview doesn't resolve, check: provider auto-discovery is enabled, route:clear has been run after config edits, and the route isn't gated to an environment you're not in. The route name/path is configurable if it collides with an app route.storage/framework should be www-data-owned in Docker setups; a silent failure to write shows up as an empty preview index.cid: inline images and attachment links often won't resolve the way a real client handles them. Verify attachments separately (check the mailable's attachments() in a test) rather than trusting the browser render.spatie/laravel-mail-preview; the older themsaid/laravel-mail-preview name is still floating around in blog posts. Pin the Spatie package in composer.json and check its README — config file names and key names have shifted between the original and the fork, so don't blindly copy snippets from old tutorials.How can I help you explore Laravel packages today?