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.

  1. Did the code ask to send? Unit-test territory: Mail::fake() plus assertions. Fast, no SMTP, no network.
  2. 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 curl and jq:
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; use assertQueued() 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 .env is enough for a test suite to mail customers; alwaysTo() outside production is cheap insurance.

Next steps