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 Viewer Laravel Package

masterro/laravel-mail-viewer

Laravel Mail Viewer captures outgoing emails from your app and stores them so you can browse them in a built-in web UI. Inspect recipients, subjects, and rendered HTML without a real inbox—handy for local development, QA, and debugging mail notifications.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish.

composer require masterro/laravel-mail-viewer
php artisan vendor:publish --provider="MasterRO\MailViewer\MailViewerServiceProvider"
php artisan migrate

Laravel's package discovery registers the service provider automatically — no manual entry in config/app.php unless you've disabled discovery. The publish step should give you a config file (usually config/mail-viewer.php) and a migration that creates the table backing the stored messages. If the provider publishes tagged assets, php artisan vendor:publish with no arguments will list the available tags — do that first if you're unsure of the tag names.

Find the route before you guess the URL.

php artisan route:list | grep -i mail

The package registers its viewer route from the provider, so this is the fastest way to confirm the URI and the middleware stack actually applied to it. Don't hardcode /mail-viewer into a bookmark or a README until you've seen it in route:list.

First use case — confirm it works end to end.

php artisan tinker
>>> Mail::raw('hello from the viewer', fn ($m) => $m->to('[email protected]')->subject('Smoke test'));

Then hit the route. If the message appears, the storage hook is wired to the mail events correctly. If it doesn't, jump straight to Gotchas.

Three files to read first, in this order:

  1. config/mail-viewer.php — the enabled flag, retention, and middleware are almost always here.
  2. The published migration — tells you exactly what's captured (subject, recipients, HTML body, attachments?).
  3. The package's service provider in vendor/masterro/laravel-mail-viewer/src/ — shows which mail events it listens to (MessageSending vs MessageSent) and therefore when a mail becomes visible.

That last point matters more than it sounds: if it hooks the sending event, a mail that later throws during transport still shows up in the viewer. If it hooks the sent event, it won't. Know which one you have before you trust the viewer as a debugging record.

Implementation Patterns

Gate it by environment, not by hope. The single most important config change is turning the viewer off outside local/staging. Wire it to the env rather than a hardcoded boolean:

// config/mail-viewer.php
'enabled' => env('MAIL_VIEWER_ENABLED', app()->environment('local', 'staging')),

Then set MAIL_VIEWER_ENABLED=false in .env.production explicitly. Defence in depth: also attach auth middleware (see below), because a single misconfigured env shouldn't expose every outbound email your app has ever sent.

Layer your own middleware on the viewer route. Most installs of this package put the viewer behind web and nothing else. Override in config:

'middleware' => ['web', 'auth', 'can:viewMailViewer'],

and define the gate in AuthServiceProvider. For a staging box with no login, use HTTP basic auth via a middleware or put the whole thing behind a VPN — this is an email archive, treat it like one.

Use it as a rendering harness for complex Mailables. The real day-to-day value isn't "see the email" — it's iterating on markup without round-tripping through Gmail. The loop:

  1. Edit the Blade view for the Mailable.
  2. Re-trigger the send from a tinker one-liner or an artisan command you keep around:
// app/Console/Commands/PreviewMail.php — keeps a reusable trigger
Artisan::call('mail:preview', ['mailable' => OrderShipped::class, 'id' => 1]);
  1. Refresh the viewer, look at the stored HTML.

Because the HTML is persisted, you get a stable artifact you can diff between attempts, which a live SMTP inbox never gives you.

Use it to validate data-dependent Mailables. The most common production mail bugs are "renders fine with seeded data, explodes with a null relation." Create a throwaway route or command that dispatches the Mailable against a deliberately awkward record (missing relation, empty collection, non-ASCII name, very long subject) and check the stored output. This catches null-safety errors that Mail::fake() will happily ignore since nothing renders.

Pair it with Mail::fake() rather than replacing it. They answer different questions:

  • Mail::fake() + assertSent → "did the app attempt to send the right Mailable to the right person?" Use in tests.
  • Mail viewer → "does the rendered output actually look right?" Use manually, in dev.

Keep Mail::fake() in your test suite. The viewer is a human-facing tool, not an assertion mechanism, and writing tests against its storage table couples your suite to a 64-star dependency.

Queued mail is delayed mail. If your Mailables implement ShouldQueue, nothing shows up until a worker picks the job up. In dev that means either running php artisan queue:work in a second terminal or setting QUEUE_CONNECTION=sync locally. If mail silently fails to appear, check the queue before you check the package.

Schedule retention pruning. The table grows monotonically. Whatever config key controls retention, back it with a scheduled prune (the package may ship a command — check php artisan list | grep -i mail), and if it doesn't, write one:

// routes/console.php
Schedule::call(fn () => DB::table('mail_viewer_emails')
    ->where('created_at', '<', now()->subDays(7))->delete())->daily();

Extend rather than fork. If you need to associate a stored email with a tenant or the user who triggered it, the cheapest path is a follow-up migration adding a nullable tenant_id column plus a small model listener/observer — not editing vendor code. Anything you patch in vendor/ is gone on the next composer update.

Don't run it alongside Telescope's mail watcher unless you mean to. Both hook the mail pipeline and both persist bodies. In a memory-constrained container that's duplicate storage for the same data; pick one.

Gotchas and Tips

Mail::fake() short-circuits everything. Faked mail never hits the transport and, in most implementations, never reaches the package's event listener either. If your viewer is empty in a test or a seeding run, look for a Mail::fake() call first — this is the number one "the package is broken" false alarm.

Config caching will bite you. After changing enabled or middleware, run php artisan config:clear (or config:cache if you're in a deploy pipeline). A stale cached config is the second most common reason a change "had no effect."

Attachments can bloat the table fast. If the migration stores attachment contents as base64 in a LONGTEXT/JSON column, a few PDF-laden emails will dwarf the rest of your dev database and make mysqldump painful. Check the schema; consider disabling attachment capture in config, or truncating aggressively. Storing the body only (with attachment filenames) is usually enough for markup review.

External assets won't load. The viewer serves stored HTML, not a live mailbox. Remote images, web fonts, and CDN-linked CSS will either render broken or get blocked. Inline your CSS (which you should be doing for email anyway — Gmail strips <head> styles) so what you see in the viewer approximates what recipients get. Don't treat a broken image icon in the viewer as a bug in your template.

Test transactions can roll back the capture. If your integration tests use RefreshDatabase with transactional wrapping, any mail captured inside a test vanishes at teardown. Harmless, but confusing if you're expecting to inspect the viewer after a test run.

Route caching is fine; route absence is not. If php artisan route:list shows nothing, check composer.json for a dont-discover entry that excludes the package, and confirm the published config exists — some providers refuse to register routes without it.

Notification mail may not be captured. Notifiable mail notifications route through a slightly different path than Mail::send(). Verify with a real notification before assuming your password-reset and invoice emails are all in the viewer.

Watch for the MessageSending vs MessageSent distinction when debugging failures. If a mail throws mid-transport, a sending-hook install will show you the HTML — which is exactly what you want — while a sent-hook install will show nothing, and you'll waste time hunting a template bug that doesn't exist.

Health-check the dependency itself. 64 stars and zero dependents is a single-maintainer package. Pin it ("masterro/laravel-mail-viewer": "^2.0" or whatever the current major is — check the tags), read the service provider once so you understand the hook, and keep a fallback in mind. If it ever breaks against a new Laravel major, the mainstream escape hatches are Mailpit/MailHog as a local SMTP sink (captures everything, zero app code) or Laravel's built-in log mail driver. Both are maintained by people who aren't you, which is the point.

The log driver is not a substitute. Writing mail to storage/logs/laravel.log gives you raw MIME — technically complete, practically unreadable for markup review. The whole value proposition here is a rendered HTML body with a UI, so don't "simplify" by dropping the package and tailing the log unless you're debugging transport, not design.

Extension points worth knowing: published views (re-publish with the views tag to override the UI), published config, the model behind the table (bind/extend if you need extra columns), and middleware — which is the only sanctioned way to gate access. Everything else means touching vendor/.

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.
robertmarney/lara-hierarchial-collections
simonschaufi/rule-doc-generator
emissaoja/emissaoja
blackbricksoftware/gravityforms-civicrm-form-processor
al-rahabi/fancy-crud
jinas/dhivatar-component
mltstephane/laravel-quality
bernskiold/laravel-talentlms
bernskioldmedia/laravel-form-architect
youbar/easy-crud
c-point-labs/laravel-hostyourai
dbravoan/dbcms-admin-theme-dbcms
dbravoan/dbcms-theme-db-cyber
dbravoan/dbcms-blocks-basic
dbravoan/dbcms-module-content-audit
dbravoan/dbcms-module-ai-assistant
paygate-to/paygate-php
padosoft/askmydocs-connector-base
defstudio/supercharged-carbon
bernskioldmedia/laravel-matomo