Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Mail Preview Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.
  • Overlay is web-HTML only. It attaches to responses going through the 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.
  • Queued mails don't produce an overlay. The send happens in a worker process, not the request that triggered it. Locally, either set 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 cache surprises. After adding Route::mailPreview() to routes/web.php, run php artisan route:clear. A stale cached route manifest makes the macro look broken.
  • With 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.
  • Missing previews on queued mail usually means a permissions problem, not a config problem — the queue worker user needs write access to storage/email-previews, not just the web server user.
  • Extension points worth knowing: the 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
alesima/laravel-azure-service-bus
tomshaw/shopcart
likyly/laravel
darthsoup/shoppingcart
tdt/core
labyrinthe/connect-php
encore2/laravel-admin2
cboxdk/laravel-console-kit
shapecode/doctrine-dbal-datetimeutc
gilads-otiannoh254/inertia-protocol
dakiquang/laravel-oauth-refresh-lock
woda/laravel-worktrees
dalefranks/ramasite
faghy/laracashbook
roundly-consulting/google-places-for-laravel
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel