Sending Mail

Mail::to sends or queues a mailable; the log and array transports keep development mail local, and Mail::fake asserts without sending.

SyntaxMail::to($user)->cc($managers)->queue(new OrderShipped($order));

Sending is one call on the Mail facade: Mail::to($user)->send(new OrderShipped($order)). to() takes an address, an object with email and name attributes, or a collection of them, and cc() and bcc() chain onto it. send() talks to the transport inside the request, so the user waits for it.

Queueing

queue() pushes a job instead, and later($when, $mailable) delays it. The same decision has two spellings: Mail::to($user)->queue($mailable) queues at the call site, while class OrderShipped extends Mailable implements ShouldQueue queues every send of that class even when the caller writes send(). Both produce the same queued job, and neither delivers anything until a worker is running.

Development transports

MAIL_MAILER selects an entry from the mailers array in config/mail.php. The log mailer writes the whole message to the log channel. The array mailer keeps messages in memory and delivers nothing, which is what a test suite wants. Mail::alwaysTo('[email protected]'), called from a service provider's boot(), rewrites every recipient and discards cc and bcc.

Asserting instead of sending

Mail::fake() replaces the mailer in the service container with a recorder. Nothing leaves, and Mail::assertSent(OrderShipped::class), assertNotSent(), assertNothingSent() and assertSentCount(2) inspect what would have. The second argument may be a count, an address, or a closure receiving the mailable, so fn ($mail) => $mail->hasTo($user) checks the recipient. A ShouldQueue mailable is recorded as queued even when the code calls send(), so it needs assertQueued(); the failure message says so.

Example

Example · php
<?php
// app/Http/Controllers/OrderShipmentController.php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Mail\OrderShipped;
use App\Models\Order;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;

class OrderShipmentController extends Controller
{
    public function store(Request $request, Order $order): RedirectResponse
    {
        $order->markShipped();

        // queue() hands the mailable to the queue; the response does not
        // wait for the mail provider. A worker sends it.
        Mail::to($order->customer)
            ->cc($order->watchers)
            ->queue(new OrderShipped($order, $order->invoicePdf()));

        // One mailable per recipient: to() appends, it does not replace.
        foreach (['[email protected]', '[email protected]'] as $address) {
            Mail::to($address)->queue(new OrderShipped($order, $order->invoicePdf()));
        }

        return redirect()->route('orders.show', $order)
            ->with('status', 'Shipping confirmation sent.');
    }
}

When to use it

  • Queue a receipt so checkout does not wait on the mail provider.
  • Send a trial-ending reminder ten minutes after an action instead of immediately.
  • Run the whole test suite with no mail transport configured.
  • Read a rendered email during development without signing up to a provider.

More examples

A test that proves the mail without sending it

Mail::fake() must be called before the code under test, because it swaps the container binding that the code will resolve. Asserting on the mailable's own public properties ($mail->order) is how you check the right record was mailed without rendering the template; what the template says is worth a separate test against the mailable itself.

Example · php
<?php
// tests/Feature/OrderShipmentTest.php

declare(strict_types=1);

namespace Tests\Feature;

use App\Mail\OrderShipped;
use App\Models\Order;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;

class OrderShipmentTest extends TestCase
{
    public function test_shipping_an_order_mails_the_customer(): void
    {
        Mail::fake();

        $order = Order::factory()->create();

        $this->post(route('orders.ship', $order))->assertRedirect();

        // OrderShipped implements ShouldQueue, so it is QUEUED, not sent.
        Mail::assertQueued(
            OrderShipped::class,
            fn (OrderShipped $mail): bool => $mail->hasTo($order->customer->email)
                && $mail->order->is($order),
        );

        Mail::assertQueuedCount(1);
    }
}

Keeping development mail out of real inboxes

The log mailer is the zero-dependency option: MAIL_MAILER=log and the full MIME message, headers and all, appears in storage/logs. Tests are better off with MAIL_MAILER=array, set in phpunit.xml rather than .env, so a test that forgets Mail::fake() still cannot reach the internet.

Example · dotenv
# .env for local work: every message is written to the log, nothing is sent.
MAIL_MAILER=log
MAIL_LOG_CHANNEL=stack
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="Example (local)"

# Or point the smtp transport at a local catch-all mailbox, such as
# Mailpit, and read the messages in a real mail client at :8025.
# MAIL_MAILER=smtp
# MAIL_HOST=127.0.0.1
# MAIL_PORT=1025

Delaying mail, and choosing the queue

Mail jobs compete with every other job on the default queue, so a slow import can delay a password reset by minutes. Giving mail its own queue name and a worker that serves it keeps that from happening. afterCommit() matters whenever the mail is queued inside DB::transaction() and the mailable loads a model created in that transaction.

Example · php
use App\Mail\TrialEnding;
use Illuminate\Support\Facades\Mail;

// Send in ten minutes rather than now.
Mail::to($user)->later(now()->addMinutes(10), new TrialEnding($user));

// Queueable gives every mailable onQueue() and onConnection().
$message = (new TrialEnding($user))
    ->onConnection('redis')
    ->onQueue('mail');

Mail::to($user)->queue($message);

// Dispatched inside a transaction? afterCommit() stops the worker
// picking the job up before the rows it needs exist.
Mail::to($user)->queue((new TrialEnding($user))->afterCommit());

Discussion

  • Be the first to comment on this lesson.