spatie/laravel-mail-preview
A Laravel mail transport that previews sent mail locally. Swap your mailer's transport to 'preview' and a small overlay appears whenever a mail is sent, linking to the rendered message. Add the middleware to the web group to enable it. Handy for testing emails in local environments.
Install it as a dev dependency:
composer require spatie/laravel-mail-preview --dev
Swap the transport on the mailer you already use locally (keep MAIL_MAILER pointing at smtp so nothing else changes):
// config/mail.php
'mailers' => [
'smtp' => [
'transport' => 'preview',
// host/port/etc. are ignored
],
],
Register the overlay middleware in the web group and the preview route:
// bootstrap/app.php (L11) or app/Http/Kernel.php
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
\Spatie\MailPreview\Http\Middleware\AddMailPreviewOverlayToResponse::class,
]);
})
// routes/web.php
Route::mailPreview(); // serves /spatie-mail-preview
First use case: trigger any mailable from a browser request (Mail::to('[email protected]')->send(new WelcomeMail)), reload the page, and click the overlay link. Every send also drops a matching .html and .eml into storage/email-previews — open the .html in a browser, or the .eml in your mail client for a realistic render. The next thing to look at is the README's assertions section, because that's where the package earns its place in a test suite.
Guard the transport per environment. Don't rely on the enabled flag alone to keep the preview transport out of production. Make it explicit:
'transport' => env('MAIL_MAILER_TRANSPORT', app()->isLocal() ? 'preview' : 'smtp'),
or define a dedicated preview mailer and set MAIL_MAILER=preview only in .env.local.
Gate the route too. Route::mailPreview() exposes raw message bodies, recipients, and CC/BCC lists. Wrap it:
if (app()->isLocal()) {
Route::mailPreview('_mail');
}
Assert on rendered content in tests. Mail::fake() never renders the mailable, so it can't tell you what the body said. Use SentMails instead, with the real transport in play:
use Spatie\MailPreview\Facades\SentMails;
public function test_invoice_email(): void
{
$this->post('/invoices')->assertRedirect();
SentMails::assertLastContains('Invoice #000234');
SentMails::assertSent(fn ($mail) =>
$mail->hasTo('[email protected]')
&& $mail->subjectContains('Invoice')
&& $mail->bodyContains('Amount due')
);
SentMails::assertCount(1);
}
Also available: assertTimesSent(), assertNotSent(), assertNothingSent() (added in 5.2), and the fluent all(), last(), count() helpers. This pairs extremely well with testing artisan commands or queued jobs where you don't have the mailable in scope — call the command, then assert on the resulting body.
Use the event for CI artifacts. Whenever a mail is stored, Spatie\MailPreview\Events\MailStoredEvent fires with $message, $pathToHtmlVersion, and $pathToEmlVersion. That's the clean hook for archiving previews in CI:
Event::listen(MailStoredEvent::class, function (MailStoredEvent $event) {
Storage::disk('artifacts')->put(
'mail/'.basename($event->pathToHtmlVersion),
file_get_contents($event->pathToHtmlVersion)
);
});
Isolate previews per test/run. Point storage_path at a per-run directory so tests and local experiments don't collide with each other:
config(['mail-preview.storage_path' => storage_path('email-previews/'.now()->format('YmdHis'))]);
Publish for customization. --tag=mail-preview-config and --tag=mail-preview-views give you config/mail-preview.php and Blade views for the overlay and the rendered mail. Adjust popup_timeout_in_seconds (false to pin it), and filename_date_format if the default Uu timestamps are unreadable in your file browser.
transport vs mailer. The string preview is a transport, not a mailer. MAIL_MAILER=preview alone does nothing unless you also add a mailers.preview entry with 'transport' => 'preview'.enabled defaults to APP_DEBUG. In phpunit.xml, APP_DEBUG is frequently true, so previews silently accumulate in storage/email-previews on every test run. Conversely, if it's false, both storage and the SentMails collection may be empty and your assertions fail for a non-obvious reason. Set the config explicitly in your test setup rather than trusting the env default.maximum_lifetime_in_seconds defaults to 60. Old previews are purged aggressively — if you're comparing renders across a session, bump this, or you'll be staring at an empty directory wondering what happened.web middleware group. Inertia, Livewire, JSON/API responses, and redirects won't display it reliably. When the overlay doesn't appear, open the files in storage/email-previews directly — that's always the source of truth.QUEUE_CONNECTION=sync while iterating on email design or watch the storage directory.Mail::fake() and SentMails don't mix. Faking prevents rendering, so the preview transport is never invoked and SentMails stays empty. Use Mail::fake() to assert a mail was dispatched; use SentMails to assert what it contains.MailStoredEvent::$message is not Swift Mailer anymore. Older docs and blog posts reference Swift_Mime_SimpleMessage; from Laravel 9 onward the underlying object is Symfony Mailer's. Don't type-hint the old class or your listener will blow up after an upgrade.Route::mailPreview() to routes/web.php, run php artisan route:clear. A stale cached route manifest makes the macro look broken.enabled = false, the preview transport doesn't deliver anything. Any code that assumes a real SMTP handshake (catching transport exceptions, reading message IDs from the send call) will silently pass. Keep a separate mailer for "real" sends when you need to exercise that path.storage/email-previews, not just the web server user.mail-preview config (storage, lifetime, timeout, filename format), the published views, and the MailStoredEvent. If you need previews piped to S3, Slack, or a snapshot-diffing pipeline, the event is the intended seam — don't reach into the transport internals.How can I help you explore Laravel packages today?