Laravel has two very different mail-testing jobs, and mixing them up is the reason a suite can be green while the produced email is broken.
- Did the code ask to send? Unit-test territory:
Mail::fake()plus assertions. Fast, no SMTP, no network. - Is the message itself correct? Integration territory: you need a real SMTP endpoint and the actual MIME output — encoding, headers, HTML part, text part, attachments.
You can cover both without a commercial sandbox. Here is how, in the order most projects adopt them.
1. Mail::fake() — prove the intent
use Illuminate\Support\Facades\Mail;
it('emails the invoice to the customer', function () {
Mail::fake();
$this->post('/invoices/42/send');
Mail::assertSent(InvoiceMail::class, function ($mail) {
return $mail->hasTo('customer@example.com')
&& $mail->invoice->id === 42;
});
});
What this proves: the mailer was called, with the right mailable, the right recipient and the right data. What it does not prove: anything about the rendered message. Mail::fake() never renders the mailable, so a broken Blade template, a missing text/ view, a bad attachment path or an encoding mistake all pass this test happily.
Also worth asserting: Mail::assertNothingSent() in the flows that must stay quiet, and Mail::assertQueued(...) when the mailable is queued — assertSent will not see it.
2. Send everything to one address
Sometimes the message does not matter — what matters is that nothing reaches real people. Laravel can redirect every outgoing message at the framework level:
// App\Providers\AppServiceProvider::boot()
use Illuminate\Support\Facades\Mail;
if (! app()->isProduction()) {
Mail::alwaysTo(config('mail.test_address'));
}
Now every mail in local and staging goes to that one address regardless of what the feature code says. It is the safety net for the classic accident: a staging environment that copied the production configuration file.
Note the difference from a sandbox: alwaysTo() changes the recipient but still sends the message — so it is the right tool for staging (where you want a real delivery) and the wrong one for a load test.
3. A real sink for the message itself
To see what your application actually builds, point it at an SMTP sandbox: Mailpit in Docker Compose, or a hosted one. The configuration does not depend on the tool:
# .env / .env.testing
MAIL_MAILER=smtp
MAIL_HOST=mailpit # or the sandbox host
MAIL_PORT=1025 # or the sandbox port
MAIL_USERNAME=anything
MAIL_PASSWORD=anything
MAIL_ENCRYPTION=null
Two things become possible that a fake cannot give you: a human looks at the rendered email (HTML with images blocked, plain-text version, links), and a test asserts on the bytes the application produced. That second one is what catches the bugs that reach customers.
Which approach catches what
| Bug | Mail::fake() |
alwaysTo() |
Real sink |
|---|---|---|---|
| The mailer was never called | yes | no | no |
| Wrong recipient or wrong mailable | yes | partially | yes |
| Broken Blade template, missing text part | no | only if a human looks | yes |
| Wrong encoding, broken attachment | no | no | yes |
A link points at localhost |
no | no | yes |
Asserting on the real message
Three ways, simplest first:
- Assertable helpers. In recent Laravel versions the closure passed to
Mail::assertSent()can assert on the rendered subject, HTML and text — check the docs for your version, because the helper set grows between releases. - The sandbox API. Mailpit exposes a REST API, so a test can ask it what it received and assert on subject, recipients, headers or body. This is the closest to end-to-end: the message really travelled over SMTP.
- The sandbox export as JSON. A read-only share link of this instance answers as JSON, so a job needs only
curlandjq:
curl -fsS "$SANDBOX_EXPORT_URL" -o captured.json
jq -e '(.items | last | .subject) == "Your invoice #1042"' captured.json
The last one works from any language in the pipeline, not just PHP.
Common mistakes
- Asserting on the whole rendered HTML. The snapshot breaks on every unrelated design tweak — assert on the subject, the recipients and two or three key strings.
- Forgetting the queue.
Mail::assertSent()does not see queued mailables; useassertQueued()or fake the queue explicitly. - Testing only the happy-path template. Send the ones that break in production: password reset, invoice with a PDF, a digest with a hundred links, and anything with non-Latin characters in the subject.
- Letting the test environment reach the real relay. One copied
.envis enough for a test suite to mail customers;alwaysTo()outside production is cheap insurance.
Next steps
- How to test email sending without sending anything — the four-step setup and the CI section.
- SMTP test server in Docker Compose — the container side of this article.
- Let an AI assistant review your test emails — the JSON export used above, in detail.
- Mailpit vs MailHog vs Mailtrap — choosing the sink.