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

Mail Intercept Laravel Package

kirschbaum-development/mail-intercept

Mail Intercept captures outgoing emails in Laravel and stores them in your database so you can inspect them without delivering to real recipients—perfect for staging and local development.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish. Mail Intercept is a standard Composer + service-provider package. There is no container, no SMTP sink, and no external account to provision.

composer require kirschbaum-development/mail-intercept
php artisan vendor:publish --tag="mail-intercept-config"
php artisan vendor:publish --tag="mail-intercept-migrations"
php artisan migrate

Open the published config/mail-intercept.php before touching anything else — it is the whole product surface. Read it top to bottom and expect to find, at minimum: an enable flag, a redirect recipient, a store-to-database toggle, and UI route/middleware settings. Confirm the exact key and env-var names there rather than in a blog post; the package is small enough that the config file is the documentation.

Gate by environment, explicitly. Interception belongs in local and (if your team agrees) staging — never in production. Put the flag in the environment files themselves, not in code:

# .env / .env.staging
MAIL_INTERCEPT_ENABLED=true
[email protected]
# .env.production
MAIL_INTERCEPT_ENABLED=false

Then add the guard rail immediately, before you send a single test mail:

// app/Providers/AppServiceProvider.php :: boot()
if (app()->environment('production') && config('mail-intercept.enabled')) {
    throw new RuntimeException('Mail interception must never be enabled in production.');
}

This is the single highest-value line of code in the whole integration. The package's failure mode is silent pass-through: when interception is off, real email goes to real customers and nothing complains. A boot-time assertion converts that into a loud crash on the one environment where it matters.

First use case: the password reset. Register a user with a real-looking address, hit "forgot password," then open the intercept UI (or query the capture table) and read the rendered HTML. That is the loop this package exists to compress — you are checking what the customer sees, not whether Mail::send() was called. Once that works, do the same for a queued notification and for a Mail::raw() send, because those are the paths that break in surprising ways.

Implementation Patterns

Redirect-only vs. store-and-browse. The two modes solve different problems and you should pick deliberately:

  • Redirect mode rewrites the recipient on every outbound message but still sends it through your real mail transport. Cheap, stateless, zero DB growth. Right for small teams already happy with Mailpit or a shared dev inbox.
  • Store mode persists the message — recipients, headers, rendered HTML, plain text, attachments — and exposes a browsable UI. This is the differentiator: mail survives restarts, non-engineers can review it, and you can resend without re-running the whole flow.

Redirect-only is a few minutes of setup. Store mode is the one that needs an owner, a retention policy, and an authorization decision.

The content-review loop. This is where the package earns its keep day to day. Marketing, support, design, and localisation reviewers get a URL instead of an engineering ticket asking someone to forward an email. To make that safe:

// config/mail-intercept.php — put the review UI behind auth
'ui' => [
    'middleware' => ['web', 'auth', 'can:view-mail-intercept'],
],

Captured messages contain live password-reset tokens and real-looking PII. In a staging environment that shares data with production, an open review route is a credential-disclosure path. Gate it, and consider disabling the UI entirely in any environment with production-derived data.

Resend is a workflow tool, not a test tool. Use it to verify a template fix or a copy change against a single captured message without re-triggering a multi-step flow. Do not use it to validate that the flow itself is correct — that is what the feature tests are for.

Asserting on rendered mail in feature tests. Mail::fake() remains the correct tool for logic-only tests (did we send, to whom, how many). Reach for the capture store when the assertion is about content: subject lines after interpolation, HTML structure, attachments, footer legal copy. Something like:

it('renders the shipped order email correctly', function () {
    Mail::to('[email protected]')->send(new OrderShipped($order));

    $message = CapturedMail::latest()->first(); // confirm the actual model/table name

    expect($message->subject)->toBe('Order #1042 has shipped')
        ->and($message->html)->toContain('Estimated delivery');
});

Because captured mail is persisted, this style of assertion works for queued mail and console-triggered mail too — real worker, real serialisation, real rendered output.

Verify every send path once, on adoption. Write a short throwaway checklist and run it once per environment:

  • Synchronous Mail::to(...)->send(...)
  • A queued Mailable through a real worker (not sync driver)
  • A queued Notification
  • Mail::raw()
  • A message with CC/BCC set
  • Each named mailer in config/mail.php, if you have more than one

The multi-mailer case is the classic gap — interception may wrap only the default mailer, in which case anything sent through a secondary mailer is not covered.

Prune on a schedule. Store mode grows monotonically and attachments make rows fat. Add an index on the capture table's timestamp column and a scheduled prune:

// routes/console.php or the scheduler
Schedule::command('mail-intercept:prune --days=14')->daily();

Use whatever command the package ships; if it ships none, a model:prune job on the capture model is five lines. Pick a retention window that matches how long you actually review mail, and treat it as a policy decision, not a default.

Pin the major version. ^1.1 in composer.json. Release 1.1.0 is a Laravel 13 compatibility bump with no stated API or behavioural change, so the upgrade from 1.0.1 is a composer update plus a test run — but the next compatibility jump should be a deliberate decision, not a surprise.

Gotchas and Tips

Silent pass-through is the real failure mode. Every other risk in this document is smaller than this one. If the flag is missing from a queue worker's environment, or the config cache is stale, or someone swaps the mail transport in an unrelated PR, mail just sends. Normal. To real people. Discovered by a customer. Design against the asymmetry: the guard rail in boot(), a CI check that asserts the flag's value in each environment's config, and a regression test that proves interception actually happens — so a package defect surfaces in your pipeline instead of an inbox.

Queue workers are a separate deployment. The intercept flag lives in the worker's environment, not the web dyno's. After changing it, php artisan queue:restart — workers hold config in memory and will happily keep using the old value. Same story for php artisan config:cache in deployments: if the flag is read via env() outside a config file, it resolves to null once cached.

Mail::fake() and interception are mutually exclusive per test. The fake swaps out the mailer, which means nothing reaches the interceptor. That is fine and expected — just do not write a test that fakes the mailer and then asserts on captured rows. Also be aware that interception during the test suite writes real rows; use RefreshDatabase so captures from one test do not leak assertions into the next.

Vendor SDK sends bypass this entirely. If any code path sends through Postmark, Mailgun, or SES SDKs directly — or opens its own SMTP connection inside a job — the package cannot see it. Audit for Http:: calls to mail APIs and for direct transport usage before assuming "all outgoing mail is intercepted." Likewise, if MAIL_MAILER=log or array in your dev environment, you are testing the log driver, not the interceptor.

vendor:publish --force will silently eat your configuration. The config file and the migration are yours once published, and the UI middleware and retention settings will live in the config. Before re-publishing after an upgrade, diff the published copies against vendor/. Migration files especially — you do not want a republished migration that no longer matches the table you already have.

Check what the UI route actually is and what it exposes. Confirm the path, the middleware stack it inherits, and whether it is registered at all in redirect-only mode. If the route is registered unconditionally, a staging deployment with the store disabled but the UI enabled can still leak whatever is in the table.

CC/BCC handling deserves an explicit test. Redirecting a message usually means rewriting To:. What happens to Cc: and Bcc: determines whether reviewers see the envelope they expect. Verify once, write it down, and stop guessing.

Attachments are the storage problem, not the row count. A thousand text emails is nothing; a hundred PDFs is a different table size and a different backup. If you exercise flows with real attachments in staging, factor that into the retention window and into whether the capture table belongs in your normal dump/restore cycle.

Treat the dependency as tactical. Small package, one maintainer organisation, MIT, low adoption. That is fine for a non-production convenience and not fine for anything with a support SLA. Keep the fallback obvious: pin the version, and if it goes dormant, redirect mode is a fifteen-line mailer decorator you can write yourself. The store-and-browse UI is the only part that is genuinely annoying to replace — which is also the only reason to prefer this package over a Dockerised Mailpit container.

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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package