Back to Blog

How to Test Emails in Laravel Without Sending to Real Users

Every Laravel developer has that moment of dread: you deploy a new feature, a background job fires, and 10,000 real customers receive a test email that says "Hello {{ $user->name }}, this is a test." It happens more often than anyone admits.

Laravel provides several layers of protection against this. The trick is knowing which tool to use at each stage of your development workflow. This guide covers all of them, from unit test fakes to SMTP sandboxes that capture every outbound message before it reaches a real inbox.

The Nuclear Option: Mail::fake()

For unit and feature tests, Mail::fake() is your first line of defense. It replaces the mail transport entirely so nothing leaves your application. No SMTP connection, no network call, nothing.

use App\Mail\WelcomeEmail;
use Illuminate\Support\Facades\Mail;

it('sends a welcome email after registration', function () {
    Mail::fake();

    $this->post('/register', [
        'name' => 'Jane Developer',
        'email' => '[email protected]',
        'password' => 'password123',
        'password_confirmation' => 'password123',
    ]);

    Mail::assertSent(WelcomeEmail::class, function ($mail) {
        return $mail->hasTo('[email protected]');
    });
});

Mail::fake() intercepts all mailables and stores them in memory. You can then assert against what was "sent" without any external dependencies.

Assertions You Should Know

Laravel ships with a full set of mail assertions. Here are the ones you will actually use:

// Assert a mailable was sent
Mail::assertSent(WelcomeEmail::class);

// Assert it was sent to a specific address
Mail::assertSent(WelcomeEmail::class, function ($mail) {
    return $mail->hasTo('[email protected]')
        && $mail->hasCc('[email protected]');
});

// Assert a mailable was sent exactly N times
Mail::assertSent(WelcomeEmail::class, 3);

// Assert nothing was sent
Mail::assertNothingSent();

// Assert a specific mailable was NOT sent
Mail::assertNotSent(InvoiceEmail::class);

Testing Queued Mail

If your mailable implements ShouldQueue, use assertQueued instead of assertSent:

use App\Mail\WeeklyReport;

it('queues the weekly report email', function () {
    Mail::fake();

    $this->artisan('reports:send-weekly');

    Mail::assertQueued(WeeklyReport::class, function ($mail) {
        return $mail->hasTo('[email protected]');
    });

    Mail::assertNotSent(WeeklyReport::class); // It was queued, not sent directly
});

This is a subtle but important distinction. If your test uses assertSent on a queued mailable, it will fail silently, and you will spend 30 minutes wondering why.

Testing Mailable Content

Laravel 11 and 12 let you assert against the rendered content of a mailable without sending it:

use App\Mail\OrderShipped;
use App\Models\Order;

it('includes the tracking number in the email body', function () {
    $order = Order::factory()->create(['tracking_number' => 'TRK-98765']);

    $mailable = new OrderShipped($order);

    $mailable->assertSeeInHtml('TRK-98765');
    $mailable->assertSeeInText('TRK-98765');
    $mailable->assertHasSubject('Your order has shipped');
});

This is useful for verifying email content without dealing with the mail transport at all. No Mail::fake() needed.

Environment-Based SMTP Configuration

Mail::fake() is for tests. For local development, you need to actually see the rendered emails in a mailbox. The .env file controls where mail goes.

Local Development with Mailpit

Laravel Sail ships with Mailpit out of the box. If you are using Sail, your .env already has this:

MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

Mailpit captures every email and provides a web UI at http://localhost:8025. Every email your app sends during development ends up there instead of a real inbox.

If you are not using Sail, you can add Mailpit to any Docker Compose setup:

services:
  mailpit:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"  # Web UI
      - "1025:1025"  # SMTP

The Problem with Local-Only Tools

Mailpit works well for solo development. It breaks down when you need to:

  • Share captured emails with teammates
  • Inspect emails from CI/CD pipeline runs
  • Test across multiple environments with persistent history
  • Collaborate on email template reviews

This is where a cloud-hosted SMTP sandbox fits in. Point your .env at a shared service and every email from every developer and every CI run lands in the same place.

Using SendPit for Team Development

MAIL_MAILER=smtp
MAIL_HOST=smtp.sendpit.com
MAIL_PORT=2525
MAIL_USERNAME=mb_your_mailbox_username
MAIL_PASSWORD=your_mailbox_password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

The credentials map to a shared mailbox that your entire team can access. Emails persist, so you can go back and inspect what was sent yesterday or last week. This is particularly useful for QA workflows where someone other than the developer needs to verify email content and layout.

Mail::alwaysTo() for Staging Environments

There is a dangerous gap between development and production: staging. Your staging environment might use real SMTP credentials because it needs to test deliverability. One wrong address in the database and a real person gets your test email.

Mail::alwaysTo() catches this. It redirects every outbound email to a single address regardless of the intended recipient:

// In AppServiceProvider.php

public function boot(): void
{
    if (app()->environment('staging')) {
        Mail::alwaysTo('[email protected]');
    }
}

Every email, no matter what address is in the to field, goes to [email protected]. The original recipient is preserved in the email headers so you can still see who would have received it in production.

You can also combine this with an SMTP sandbox. Redirect everything in staging to a sandbox mailbox and you get both the safety net and the ability to inspect rendered emails:

public function boot(): void
{
    if (app()->environment('staging')) {
        Mail::alwaysTo('[email protected]');
        // SMTP config in .env.staging already points to SendPit
    }
}

Configuration by Environment

Here is a practical setup that covers all four environments:

# .env.testing (automated tests)
MAIL_MAILER=array

# .env.local (solo development)
MAIL_MAILER=smtp
MAIL_HOST=localhost
MAIL_PORT=1025

# .env.staging (shared staging)
MAIL_MAILER=smtp
MAIL_HOST=smtp.sendpit.com
MAIL_PORT=2525
MAIL_USERNAME=mb_staging_mailbox
MAIL_PASSWORD=your_staging_password
MAIL_ENCRYPTION=tls

# .env.production (real delivery)
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=your_production_key
MAIL_ENCRYPTION=tls

The array mailer in testing stores sent mail in memory (similar to Mail::fake() but at the config level). This means even if you forget to call Mail::fake() in a test, nothing leaves the application.

A Complete Test Suite Example

Here is a realistic Pest test file that covers common email scenarios in a Laravel 12 application:

<?php

use App\Mail\InvitationEmail;
use App\Mail\PasswordResetEmail;
use App\Mail\WelcomeEmail;
use App\Models\User;
use Illuminate\Support\Facades\Mail;

beforeEach(function () {
    Mail::fake();
});

it('sends a welcome email when a user registers', function () {
    $this->post('/register', [
        'name' => 'Test User',
        'email' => '[email protected]',
        'password' => 'securepassword',
        'password_confirmation' => 'securepassword',
    ]);

    Mail::assertSent(WelcomeEmail::class, function ($mail) {
        return $mail->hasTo('[email protected]');
    });
});

it('does not send a welcome email if registration fails', function () {
    $this->post('/register', [
        'name' => '',
        'email' => 'not-an-email',
        'password' => 'short',
    ]);

    Mail::assertNotSent(WelcomeEmail::class);
});

it('sends password reset emails with a valid token', function () {
    $user = User::factory()->create();

    $this->post('/forgot-password', [
        'email' => $user->email,
    ]);

    Mail::assertSent(PasswordResetEmail::class, function ($mail) use ($user) {
        return $mail->hasTo($user->email);
    });
});

it('sends invitation emails to multiple addresses', function () {
    $user = User::factory()->create();
    $this->actingAs($user);

    $emails = ['[email protected]', '[email protected]', '[email protected]'];

    $this->post('/invitations', [
        'emails' => $emails,
    ]);

    Mail::assertSent(InvitationEmail::class, 3);

    foreach ($emails as $email) {
        Mail::assertSent(InvitationEmail::class, function ($mail) use ($email) {
            return $mail->hasTo($email);
        });
    }
});

it('includes the correct unsubscribe link in the welcome email', function () {
    $mailable = new WelcomeEmail(User::factory()->create([
        'email' => '[email protected]',
    ]));

    $mailable->assertSeeInHtml('/unsubscribe');
    $mailable->assertSeeInHtml('[email protected]');
});

Notice the beforeEach hook. By calling Mail::fake() once at the top, every test in the file is automatically protected. No email can escape.

Notification Testing

If you use Laravel Notifications instead of raw mailables, the approach changes slightly:

use App\Models\User;
use App\Notifications\AccountApproved;
use Illuminate\Support\Facades\Notification;

it('notifies the user when their account is approved', function () {
    Notification::fake();

    $user = User::factory()->create(['approved' => false]);

    $this->actingAs($adminUser)
        ->post("/admin/users/{$user->id}/approve");

    Notification::assertSentTo($user, AccountApproved::class);
});

Notification::fake() works the same way as Mail::fake() but intercepts notifications across all channels (mail, database, Slack, etc.).

Choosing the Right Approach

Scenario Tool Why
Unit/feature tests Mail::fake() Zero external dependencies, fast, deterministic
Solo local development Mailpit See rendered HTML, free, runs in Docker
Team development Cloud SMTP sandbox (SendPit) Shared visibility, persistent history
Staging Mail::alwaysTo() + sandbox Prevents accidental delivery to real users
CI/CD pipelines SMTP sandbox with API Programmatic assertions on captured mail

There is no single right answer. Most teams end up using all of these at different stages. The important thing is that at no point in your pipeline does a test email have a path to a real inbox.

Start with Mail::fake() in your tests, add Mailpit or a cloud sandbox for development, lock down staging with alwaysTo(), and you will never have another accidental email incident.

N

Nikhil Rao

Creator of SendPit. Building developer tools for email testing and SMTP infrastructure.

About SendPit →

More from the blog