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.
Test strategy for transactional email — as precedent, not as a live option. MailThief let teams assert on recipients, subject lines, rendered bodies, views and attachments in PHPUnit without touching SMTP, SES or Mailgun. That underwrote real product decisions: "can this notification flow ship behind a test gate?" and "is the marginal cost of adding one more transactional email low enough to put it on the roadmap?" The decision is still worth making; the tool that serves it is now the framework's first-party Mail::fake() (Laravel 5.5+). Treat MailThief as the historical answer to a question you should keep asking.
Build vs. buy on test tooling. The v0.3.14 mailable assertions removed helper code a team would otherwise write and own. The reusable principle — buy the assertion layer, don't hand-roll it — holds. What inverted is the vendor: "buy" now means the framework itself, free, first-party, shipped on the framework's own cadence. Any roadmap item proposing custom mail-assertion scaffolding should be closed against that.
Diagnosability as a product requirement. Specific failure messages (the "subject not found" improvement instead of a vague assertion failure) shorten the loop on copy edits, localization and per-tenant subject lines — which means smaller, more frequent content tickets instead of batched rewrites. That's an argument for diagnosability as an acceptance criterion on whichever mail fake you standardize, not an argument for this package.
Platform standardization across Laravel and Lumen services. More accurate Lumen version detection was an input into whether a Lumen service could share one testing convention with the Laravel estate. Still the right question; answer it with Mail::fake() plus a documented pattern rather than a third-party double.
Dependency lifecycle and exit planning. The decisive facts are lifecycle, not features: archived, last release 2018-02-10, pre-1.0 (0.x), 0 listed dependents, superseded by first-party capability since August 2017. The PM decision this now supports is deprecation. Use it as the standing case study for a rule: every new test-only dependency needs a named exit path tied to a framework upgrade, or it becomes permanent debt.
Risk posture on dev-only dependencies. Blast radius is low because it never runs in production — but "dev-only" is not "risk-free." An archived package's composer.json constraints eventually conflict, and the failure surfaces as red CI during an unrelated upgrade. That's a roadmap input (upgrade sequencing, tech-debt budget), not just an engineering footnote.
Consider it — narrowly, with eyes open:
Mail::fake(), the suite already depends on MailThief, and the alternative is no mail assertions. Keeping it pinned while the service waits for a funded framework upgrade is defensible as a bridge with a named expiry date.Look elsewhere — the default for essentially all new work:
Mail::fake() with assertSent, assertQueued, assertNothingSent. First-party, queued-mail aware, and removes a dependency.ShouldQueue mailables dispatched to a database or redis queue never reach the in-memory store, so tests pass green while asserting nothing. Silent-pass fakes are worse than no fakes.To executives: We inherited a test-only tool that verified transactional email — welcome messages, receipts, password resets — without sending anything. It did its job, but it has been archived since 2018, is superseded by a capability the framework now ships for free, and is used on no new work. There is no customer-facing behavior to protect here; the risk is that an unmaintained dependency blocks a future framework upgrade and turns into an unrelated CI outage with an unclear owner. Recommendation: deprecate and budget the removal as part of the next framework bump, not as standalone work. Cost is small and bounded; benefit is one fewer unsupported constraint in the build.
To engineering: MailThief was a reasonable buy in 2016–2017 and the wrong one today. Mail::fake() covers everything it did — recipients, subjects, bodies, views, attachments — plus queued mail, which MailThief silently missed. Two things we should carry forward rather than discard: (1) prefer assertions on mailable class, subject and view name over rendered body copy, so content edits don't turn the suite red; (2) keep failures diagnosable — a fake that says "expected subject X, saw Y" saves more hours than one that says "assertion failed." Migration is mechanical and one-to-one (seeMessageFor($addr) → Mail::assertSent(Mailable::class, fn ($m) => $m->hasTo($addr)); dontSeeMessage → Mail::assertNothingSent()). Do it test-file by test-file, remove MailThief::hijack() from the base TestCase last, and audit every inherited mail assertion with one question: would this still pass if the mail were never sent? If yes, it's decorative and needs a real assertion before we call the migration done.
The ask: approve a dated removal ticket tied to the next Laravel upgrade; adopt a standing rule that every new test-only dependency carries a named exit path. If any service genuinely cannot upgrade in that window, record it as an explicit, owned exception rather than letting it sit unnamed.
How can I help you explore Laravel packages today?