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.
MailThief is a test-time service double, not a runtime component. Architecture fit must be judged on that basis: it swaps Laravel's mailer container binding for an in-memory store in setUp() and never executes in production. Blast radius is therefore low, but "dev-only" is not "risk-free" — the cost shows up as dependency lifecycle debt and test-suite credibility, not uptime.
Architecture fit. The mechanism is a single container rebind plus macros registered on the test case. That is a narrow, well-understood seam and it integrates cleanly with any Laravel 5.1–5.4 or Lumen application that sends mail through Mail:: (or the MailThief facade alias). It is a poor fit for anything else: mail sent via a directly resolved Swift_Mailer, package-owned transports, or queue worker processes is invisible to the store. The package is structurally a fake, not an adapter — there is no transport, no provider integration, no delivery observability, and no path to production use.
Integration feasibility. High for the target environment, low everywhere else. Install is a single composer require --dev pinned to 0.3.14; Laravel auto-discovers the service provider; Lumen needs manual registration plus an alias in bootstrap/app.php. The whole setup is roughly one base TestCase change and a handful of assertion macros. The real feasibility constraint is version, not effort: on Laravel 5.5+ the correct answer is the framework's own Mail::fake(), which arrived August 2017 and covers recipients, subjects, bodies, attachments, and queued mail. MailThief's remaining addressable surface is legacy codebases that predate that capability.
Technical risk. Ranked by what would actually hurt:
| Risk | Severity | Why it matters to a TPM |
|---|---|---|
| Queued-mail blind spot | High | ShouldQueue mailables dispatched through database/redis never reach the store. Tests stay green while asserting nothing — a silent pass, which is worse than no test at all. Any roadmap item that relies on "mail is covered by tests" is unsound until this is audited. |
| Archived / unmaintained | High | Last release 2018-02-10, 0.x line, 0 listed dependents, no upstream to escalate to. Constraints will eventually conflict with a PHP or Laravel bump and surface as red CI during an unrelated upgrade. |
| PHPUnit/PHP 8 incompatibility | Medium | Predates typed setUp(): void; signature-mismatch fatals on PHPUnit 8+. Forces either a pinned legacy toolchain or an exit. |
| Binding collision during migration | Medium | MailThief::hijack() and Mail::fake() both replace the mailer binding; last writer wins, producing confusing "no message found" failures mid-migration. |
| Container-singleton state leakage | Medium | Phantom messages appear if tests bypass the base class or if mail fires during boot/observer registration. Erodes trust in results. |
| Pre-1.0 API instability | Low-Medium | Breaking changes are permitted on minor bumps, and there will be no releases to fix fallout. Pin exactly. |
Key questions I would put to engineering before endorsing any continued use:
body content or to to array internals rather than subject/view/mailable class? Those are the ones that will break for no product reason.Mail::fake().Stack fit. Narrow and version-gated. Fit: Laravel 5.1–5.4, Lumen, PHPUnit versions contemporary with those releases, require-dev only, pinned to 0.3.14 (a caret constraint on a dead 0.x line buys nothing). No fit: Laravel 5.5+, greenfield work, any suite that needs queued-mail coverage, any team wanting one consistent faking convention across queues, HTTP, storage and time. The decision-relevant fact is that MailThief was superseded by the framework roughly six months before its final release — the "build vs. buy" question inverted, and the buy option is now free and on the framework's own cadence.
Migration path. Treat this as a one-way exit with a clean mapping, not a co-existence strategy. The one-to-one translation is mechanical:
seeMessageFor($addr) → Mail::assertSent(Mailable::class, fn ($m) => $m->hasTo($addr))seeMessageWithSubject(...) / view / attachment checks → assertions inside the assertSent closure against typed mailable propertiesdontSeeMessage() → Mail::assertNothingSent()Mail::assertQueued(...), which is where the legacy tool simply has no answerTwo sequencing rules make or break it. First, wrap recurring assertions in a project trait now, even while still on MailThief. Recipient addresses and subjects are stringly-typed and drift; a single assertWelcomeEmailSentTo($address) trait turns the eventual migration into a one-file change instead of a suite-wide grep. Second, migrate test-file by test-file, and remove hijack() from the base TestCase last. Leaving both mechanisms live in the same run guarantees binding-collision failures that look like product bugs.
Compatibility. Two hard constraints to verify before committing: the PHP/PHPUnit pairing (the typed-setUp fatal effectively caps you at a pre-PHPUnit-8 toolchain, or forces the exit early), and composer.json conflict risk against modern Laravel/Swiftmailer versions. Both are checks, not negotiations — if either fails, the migration is no longer optional and should be pulled forward into the next framework upgrade rather than run as standalone work.
Sequencing. Recommended order: (1) pin 0.3.14 in require-dev and box it there; (2) introduce the assertion trait and route new tests through it; (3) run the queued-mail audit and label any silent-passing tests as known-broken rather than covered; (4) migrate file-by-file to Mail::fake(), deleting hijack() from the base class as the final step; (5) remove the dependency in the same PR as the framework bump, so the composer conflict and the removal are one change rather than two. For services that genuinely cannot upgrade yet, record the continued use as an explicit, owned exception with an expiry date — not an unnamed default.
Maintenance. Zero upstream. The repository is archived, the last release is 2018-02-10, and the 0.x line means no compatibility work will ever land. All maintenance is inherited by the consuming team: pinning, working around PHPUnit signature fatals, and absorbing composer conflicts. Budget this as tech-debt carrying cost, not as a line item that decays over time — it only gets worse.
Support. No vendor, no maintainer, no release notes past 0.3.14. Support is "read the source" plus whatever institutional memory exists in the team that adopted it. That memory is the real asset at risk: the package's API surface is small (hijack(), lastMessage(), allMessages(), the message object, and half a dozen macros), so ramp-up for a new engineer is genuinely a single afternoon — but the reasoning behind the assertions (why subject, not body; why mailable class, not string) walks out the door with the last person who wrote them unless it is written into the trait and its docblocks.
Scaling. Fine along the dimension that matters and silent along the one that doesn't. The store is an in-memory container singleton reset per test, so it scales with test count and parallel processes without external infrastructure — no SMTP, no driver config, no network. It does not scale to queued or asynchronous mail at all, and it does not scale across frameworks: every additional service on 5.5+ is a service where this package is pure overhead. Suite-wide adoption beyond the legacy cohort increases divergence from the standard convention rather than reducing it.
Failure modes. Five to plan for, in order of damage:
hijack() in setUp(), keep every test on the base class, dump count(MailThief::allMessages()) first when a count looks wrong.Mail::fake() — mitigation: strict per-file migration, base-class hijack() removed last.require-dev, tie removal to the framework bump, and pre-emptively flag the conflict to whoever owns upgrade sequencing.Ramp-up. Low technical ramp-up, moderate judgment ramp-up. A competent Laravel engineer is productive in an afternoon; the harder skill is knowing which assertions are worth writing — subject, view, and mailable class survive content edits and localization changes, rendered bodies do not. Preserve the one trait genuinely worth carrying forward: diagnosability. The 0.3.14 "subject not found" improvement existed because vague failures on subject typos and per-tenant strings cost real diagnosis time. Whatever replaces this — and it should be Mail::fake() — make specific, readable failure messages an acceptance criterion. A fake that says "expected subject X, saw Y" saves more engineering hours than one that says "assertion failed," and that is the durable product lesson this package leaves behind.
How can I help you explore Laravel packages today?