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

Mailthief Laravel Package

tightenco/mailthief

MailThief is a fake mailer for Laravel that intercepts outgoing email during tests, letting you inspect recipients, subjects, and message bodies without sending anything. It makes asserting on mail simple, fast, and reliable, with no real SMTP or inbox needed.

View on GitHub
Deep Wiki
Context7

Getting Started

MailThief is a legacy-environment tool, not a default choice. Before writing any test code, check the app's Laravel version. On 5.5+ use the framework's own Mail::fake() — it covers recipients, subjects, bodies, attachments and queued mail, and is maintained. Everything below assumes you're stuck on Laravel 5.1–5.4 or Lumen that predates first-party fakes, or you're auditing a legacy suite before migrating off.

1. Install pinned, as a dev dependency.

composer require --dev tightenco/mailthief:0.3.14

Pin the exact version. It's a 0.x line with no future releases, so a caret constraint only buys surprises.

2. Register the provider.

Laravel auto-discovers MailThief\MailThiefServiceProvider. On Lumen wire it manually in bootstrap/app.php:

$app->register(MailThief\MailThiefServiceProvider::class);
class_alias(MailThief\Facades\MailThief::class, 'MailThief');

3. Hijack the mailer once in a base TestCase.

use MailThief\Facades\MailThief;

abstract class TestCase extends BaseTestCase
{
    public function setUp()
    {
        parent::setUp();

        MailThief::hijack();
    }
}

hijack() swaps the container's mailer binding for an in-memory store; anything sent through Mail:: afterwards is captured rather than delivered.

4. First use case — assert a welcome email went out.

public function test_registration_sends_welcome_email()
{
    $this->post('/register', ['email' => '[email protected]']);

    $this->seeMessageFor('[email protected]');
    $this->seeMessageWithSubject('Welcome to Acme');
}

If that passes, nothing left the machine. If it fails, you get a usable failure message — that diagnosability is the one trait worth carrying into whatever replaces this.

Where to look first: the MailThief facade and the captured message object. MailThief::lastMessage() is your inspection entry point — dump it once and the whole surface (subject, body, view, to, from, attachments) becomes obvious.

Implementation Patterns

Two assertion styles — pick deliberately. The package exposes test-case macros (assert-and-fail, good for straight-line happy paths) and a queryable store (good for anything conditional):

// Macros, registered on the test case by the service provider
$this->seeMessageFor('[email protected]');
$this->seeMessageWithSubject('Welcome to Acme');
$this->seeMessageWithBody('Start your trial');
$this->seeMessageWithAttachment('invoice.pdf');
$this->dontSeeMessageFor('[email protected]');
$this->dontSeeMessage();

// Store — when you need to branch or inspect directly
$message = MailThief::lastMessage();

$message->subject;      // string
$message->body;         // rendered HTML/text
$message->view;         // view name, if sent from a view
$message->to;           // array of ['name' => ..., 'address' => ...]
$message->from;
$message->attachments;

Assert on subject, view, and mailable class — not the rendered body. Asserting $message->body embeds marketing copy into the test suite; every content tweak turns red. Version 0.3.14 added the ability to assert against the mailable itself — reach for that whenever the code sends a mailable class rather than Mail::raw(). Subject assertions survive body rewrites; view-name assertions survive both.

Recipient arrays are structured, not strings. $message->to is a list of name/address pairs. Use seeMessageFor() rather than hand-matching ->to internals, otherwise how the package populates names becomes load-bearing in tests for no product reason.

Wrap recurring assertions in a project trait, not duplicated strings. Recipient addresses and subjects are stringly-typed and drift. One trait with methods like assertWelcomeEmailSentTo($address) (delegating to MailThief internally) makes the eventual migration to Mail::fake() a one-file change instead of a suite-wide grep.

Attachments: test the filename and the type you actually attach. attach() and attachData() produce different shapes. Assert on the filename the recipient would see, and write one test per distinct attachment rather than a loop — when it fails you want the failing filename in the test name.

Structure queue-aware flows to be observed. If a notification is a ShouldQueue mailable dispatched via Mail::queue(), the message frequently never reaches the store. Send synchronously in the test (Mail::send()), or use Queue::fake() + assert the job, then instantiate the job and call handle() so the assertion actually observes something. Don't assume green means queued mail works.

Migrate one-to-one when the time comes: seeMessageFor($addr) → Mail::assertSent(Mailable::class, fn ($m) => $m->hasTo($addr)); dontSeeMessage → Mail::assertNothingSent(); attachment and body checks move inside the assertSent closure against typed mailable properties. Do the migration as part of a framework bump, not standalone work.

Gotchas and Tips

The queued-mail blind spot is the big one. ShouldQueue mailables dispatched to a database or redis queue never reach the in-memory store. Tests stay green while asserting nothing — a silent pass, worse than no test. Audit every inherited mail assertion with the question, "would this still pass if the mail were never sent?" If yes, the test is decorative.

Order of operations with Mail::fake(). Both mechanisms replace the mailer binding. If a base TestCase calls MailThief::hijack() while an individual test calls Mail::fake(), whichever ran last wins and the other's assertions fail with confusing "no message found" errors. During migration, go test-file by test-file and remove hijack() from the base class last.

The store is a container singleton. hijack() in setUp() gives a fresh instance per test, which is what you want. If a test bypasses the base class, or mail is sent during application boot (service provider, model observer registered at container resolution), you'll see phantom messages from a previous test or from boot. When a count looks wrong, dump count(MailThief::allMessages()) at the top of the test before doing anything else.

Mail sent outside the Mailer is invisible. Anything resolving Swift_Mailer directly, sending from a queue worker process, or going through a package that owns its own transport will not be intercepted. If a "should have sent mail" test finds nothing, first confirm the code path actually calls Mail::send()/Mail::to()->send() and not a lower-level Swift call.

body is often empty when a mailable is queued or lazily rendered. Assert on view and view data instead — it's stable across content edits and doesn't depend on render timing.

Lumen registration is per-service and easy to lose on a rebuild of bootstrap/app.php. If macros like seeMessageFor() suddenly don't exist, the service provider isn't registered — the error will point at the test method, not the bootstrap file.

PHP 8 and modern PHPUnit will fight you. The package predates typed setUp(): void, so PHPUnit 8+ throws signature-mismatch fatals. Either keep the legacy PHPUnit pinned alongside it, or — better — treat that fatal as the trigger to migrate to Mail::fake() and delete the dependency.

Failure messages are the redeeming feature — read them. The 0.3.14 "subject not found" improvement exists because vague failures on subject typos, localization strings, and per-tenant subjects cost real diagnosis time. Whatever replaces this package, keep diagnosability as a requirement — a fake that says "expected subject X, saw Y" saves more engineering hours than one that says "assertion failed."

Know your exit. Archived, last released 2018-02-10, zero listed dependents. It will eventually block a PHP or Laravel upgrade via composer.json constraints, and the failure will surface as red CI during an unrelated bump. Pin it, box it into require-dev, file a dated removal ticket tied to the next framework upgrade, and if a service genuinely can't upgrade yet, record that as an explicit owned exception rather than letting it sit unnamed.

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.
elsayed85/laravel-api-exceptions
ouhssini/laravel-demo-mode
daworks/ncloud-outbound-mailer-driver
gp247/cms
ignibyte/aic-gates
nexa-merchant/blog
novasa/laravel-language-center
opencage/geocode
adolfoholzer/parameters
livenetworks/ln-starter
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
cgv/localized-resource