Mail and Notifications

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Laravel’s mail component wraps Symfony Mailer. A Mailable class describes one kind of email, and a notification describes one kind of message that can go out over several channels, such as mail, the database or Slack. Bookshelf sends a loan receipt and an overdue notice. Every message below was delivered to a local Mailpit server (v1.31.4) and read back through its API. Sending mail without a framework, with mail() and Symfony Mailer directly, is on HTTP Clients, APIs and Email.

Mailers

config/mail.php defines mailers: smtp, ses, postmark, resend, sendmail, log (writes the message to the log), array (keeps it in memory), and the combining failover and roundrobin transports. A new application uses log. Bookshelf points smtp at Mailpit, which accepts everything and shows it in a web inbox:

MAIL_MAILER=smtp
MAIL_HOST=172.18.0.6        # the Mailpit container; Sail uses mailpit:1025, the skeleton .env 127.0.0.1:2525
MAIL_PORT=1025
MAIL_FROM_ADDRESS="loans@bookshelf.test"
MAIL_FROM_NAME="${APP_NAME}"

A Mailable

php artisan make:mail LoanReceipt --markdown=mail.loans.receipt creates the class and a Markdown view. The class returns the parts of the message from separate methods:

<?php
// app/Mail/LoanReceipt.php
namespace App\Mail;

use App\Models\Loan;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Attachment;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\{Content, Envelope, Headers};
use Illuminate\Queue\SerializesModels;

class LoanReceipt extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;

    public function __construct(public Loan $loan)
    {
        $this->afterCommit();                                    // never mail a loan that was rolled back
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: __('Your loan of :title', ['title' => $this->loan->book->title]),
            tags: ['loan-receipt'],
            metadata: ['loan_id' => $this->loan->id],
        );
    }

    public function headers(): Headers
    {
        return new Headers(text: ['X-Bookshelf-Loan' => $this->loan->id]);
    }

    public function content(): Content
    {
        return new Content(markdown: 'mail.loans.receipt', with: ['dueAt' => $this->loan->due_at]);
    }

    public function attachments(): array
    {
        $ics = implode("\r\n", ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Bookshelf//EN', 'BEGIN:VEVENT',
            'UID:'.$this->loan->id.'@bookshelf.test', 'DTSTAMP:'.now()->utc()->format('Ymd\THis\Z'),
            'DTSTART;VALUE=DATE:'.$this->loan->due_at->format('Ymd'), 'SUMMARY:Return '.$this->loan->book->title,
            'END:VEVENT', 'END:VCALENDAR']);

        return [Attachment::fromData(fn () => $ics, 'due-date.ics')->withMime('text/calendar')];
    }
}
{{-- resources/views/mail/loans/receipt.blade.php --}}
<x-mail::message>
# {{ __('Enjoy :title', ['title' => $loan->book->title]) }}

{{ __('Please return it by :date.', ['date' => $dueAt->locale(app()->getLocale())->isoFormat('LL')]) }}

<x-mail::button :url="route('books.show', $loan->book)">
{{ __('View the book') }}
</x-mail::button>

{{ __('The due date is attached as a calendar event.') }}

{{ config('app.name') }}
</x-mail::message>

The x-mail:: components render a responsive HTML layout with inline CSS and a plain-text alternative. php artisan vendor:publish --tag=laravel-mail copies the templates for customisation. Public properties such as $loan are available in the view automatically, and with: adds more.

Sending, queueing and translating

<?php
Mail::to($user)->send(new LoanReceipt($loan));                              // ShouldQueue: queued, not sent
Mail::to('ana@example.com')->locale('es')->send(new LoanReceipt($loan));    // rendered in Spanish in the worker

Because the class implements ShouldQueue, send() queued two jobs. The request did not wait for SMTP, and the worker delivered them:

queued mail jobs: 2
 2026-10-05 22:25:19 App\Mail\LoanReceipt .. 114.46ms DONE
 2026-10-05 22:25:19 App\Mail\LoanReceipt .. 63.61ms DONE

Mailpit inbox:
ana@example.com | Tu préstamo de Kindred | attachments: 1 | size: 12089
ada@example.com | Your loan of Kindred   | attachments: 1 | size: 12014

Headers of the Spanish message:
From: Laravel <loans@bookshelf.test>
Subject: Tu =?utf-8?Q?pr=C3=A9stamo?= de Kindred
X-Tag: loan-receipt
X-Metadata-Loan_id: 01m472qrf2hkeys9ga76agc3vz
X-Bookshelf-Loan: 01m472qrf2hkeys9ga76agc3vz
Content-Type: multipart/mixed; boundary=UQgcbsJ6

Text part:
# Disfruta Kindred
Devuélvelo antes del 26 de octubre de 2026.
Ver el libro: http://localhost:8000/books/1
La fecha de devolución va adjunta como evento de calendario.
Laravel
© 2026 Laravel. All rights reserved.

Attachment: due-date.ics, text/calendar, 220 bytes

The output shows several things to check before going live:

  • From: Laravel. MAIL_FROM_NAME defaults to ${APP_NAME}, and Bookshelf’s APP_NAME was still Laravel. The name also appears in the layout’s header and footer.

  • The footer is not translated. The strings that lang/es.json lists were translated, and the date was formatted in Spanish by Carbon’s isoFormat('LL'). The vendor template’s "All rights reserved." was not. Publishing the templates and wrapping their text in __() fixes that (Localization).

  • Tags and metadata are headers over SMTP. Providers such as Postmark, Resend and SES read them into their own tagging. Over plain SMTP they become X-Tag and X-Metadata-* headers that the recipient can see, so metadata must not hold anything private.

  • The non-ASCII subject was encoded as RFC 2047 =?utf-8?Q?...?= by Symfony Mailer, which needs no extra code.

afterCommit() in the constructor matters when the mail is sent inside a transaction. Without it, the worker could send a receipt for a loan that was rolled back, the same problem as with any queued job (Queues and Jobs).

Previewing

A route that returns a Mailable renders it as HTML in the browser, so templates can be checked without sending anything:

<?php
// routes/web.php: preview in the browser, local only
if (app()->isLocal()) {
    Route::get('/mail/preview/receipt', fn () => new App\Mail\LoanReceipt(App\Models\Loan::latest()->firstOrFail()));
}

The preview answered 200 text/html with 16,973 bytes on the development server, and 404 on the production-mode server. Without the isLocal() guard, the route would show a real loan to anyone in production.

Failover

A failover mailer tries its mailers in order and moves on when one throws. Bookshelf pointed a primary SMTP mailer at a port where nothing listens:

<?php
config([
    'mail.mailers.primary' => ['transport' => 'smtp', 'host' => '127.0.0.1', 'port' => 2526, 'timeout' => 2],   // nothing listening
    'mail.mailers.mailpit' => ['transport' => 'smtp', 'host' => '172.18.0.6', 'port' => 1025],
    'mail.mailers.failover' => ['transport' => 'failover', 'mailers' => ['primary', 'mailpit'], 'retry_after' => 60],
]);

Mail::mailer('primary')->raw('test', fn ($m) => $m->to('a@example.com')->subject('primary only'));
Mail::mailer('failover')->raw('The catalogue sync failed.', fn ($m) => $m->to('ops@bookshelf.test')->subject('via failover'));
primary alone: Symfony\Component\Mailer\Exception\TransportException: Connection could not be established with host "127.0.0.1:2526": stream_socket_client(): Unable to co…
failover: sent via mailpit in 56 ms, message id set
mailpit newest: ops@bookshelf.test | via failover

retry_after is how long a failed mailer is skipped before it is tried again. roundrobin spreads messages across mailers instead, which suits two providers that share the volume. Failover covers a dead connection. It does not cover a provider that accepts the message and then bounces it, which the provider’s webhooks report.

Notifications

A notification decides its channels per recipient in via() and builds one message per channel. Bookshelf’s overdue notice goes by mail and into the notifications table, which php artisan make:notifications-table creates, for an in-app inbox:

<?php
// app/Notifications/LoanOverdue.php
namespace App\Notifications;

use App\Models\Loan;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;

class LoanOverdue extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(public Loan $loan) {}

    public function via(object $notifiable): array
    {
        return ($notifiable->prefers_email ?? true) ? ['mail', 'database'] : ['database'];
    }

    public function toMail(object $notifiable): MailMessage
    {
        $days = (int) $this->loan->due_at->diffInDays(now());

        return (new MailMessage)
            ->subject("Overdue: {$this->loan->book->title}")
            ->greeting(isset($notifiable->name) ? "Hello {$notifiable->name}," : "Hello,")       // on-demand notifiables have no name
            ->line("{$this->loan->book->title} was due {$days} days ago.")
            ->action('Renew or return', url('/loans/'.$this->loan->id))
            ->line('A late fee applies from the eighth day.');
    }

    public function toArray(object $notifiable): array            // stored in the notifications table
    {
        return ['loan' => $this->loan->id, 'title' => $this->loan->book->title, 'due_at' => $this->loan->due_at->toDateString()];
    }
}
<?php
$user->notify(new LoanOverdue($loan));                                                   // Notifiable trait on User
Notification::route('mail', 'desk@bookshelf.test')->notify(new LoanOverdue($loan));     // on demand, no user

$user->unreadNotifications()->first()->data;     // ["loan" => …, "title" => "Kindred", "due_at" => "2026-09-26"]
$user->unreadNotifications()->first()->markAsRead();
queued notification jobs: 4 (one per channel per notifiable)
 2026-10-05 22:25:43 App\Notifications\LoanOverdue .. 114.80ms DONE
 2026-10-05 22:25:43 App\Notifications\LoanOverdue .. 5.51ms DONE
 2026-10-05 22:25:43 App\Notifications\LoanOverdue .. 3.60ms FAIL
 2026-10-05 22:25:43 App\Notifications\LoanOverdue .. 1.72ms DONE
unread: 1
type: App\Notifications\LoanOverdue
data: {"loan":"01m472qrf2hkeys9ga76agc3vz","title":"Kindred","due_at":"2026-09-26"}
unread after markAsRead: 0
  • A queued notification is one job per channel and recipient. A failure on one channel does not stop the others, and each is retried on its own.

  • The failing job was the desk’s mail. The first version used "Hello {$notifiable->name},", and an on-demand recipient is an AnonymousNotifiable with routes only: ErrorException: Undefined property: Illuminate\Notifications\AnonymousNotifiable::$name. After the isset guard, the desk received its notice. The database job for the on-demand recipient completed without storing a row, because there is no model to store it for. The table still held one notification.

Other channels follow the same shape:

  • toBroadcast() pushes the notification to the user’s private channel (Broadcasting and Reverb).

  • toSlack() builds Block Kit messages (laravel/slack-notification-channel).

  • toVonage() sends SMS (laravel/vonage-notification-channel).

  • Community channels, listed at laravel-notification-channels.com, cover Telegram, web push and others.

Testing

Mail::fake() and Notification::fake() record instead of sending. A Mailable can also be rendered and asserted on directly, without any fake:

<?php
// tests/Feature/MailTest.php
use App\Mail\LoanReceipt;
use App\Models\{Loan, User};
use App\Notifications\LoanOverdue;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Notifications\AnonymousNotifiable;
use Illuminate\Support\Facades\{Mail, Notification};

uses(RefreshDatabase::class);

test('a receipt is queued to the member', function () {
    Mail::fake();
    $loan = Loan::factory()->create();

    Mail::to($loan->user)->send(new LoanReceipt($loan));

    Mail::assertQueued(LoanReceipt::class, fn (LoanReceipt $mail) => $mail->hasTo($loan->user->email) && $mail->loan->is($loan));
    Mail::assertNothingSent();                                // queued is not sent
});

test('the receipt has the due date and a calendar attachment', function () {
    $loan = Loan::factory()->create(['due_at' => '2026-10-26 12:00:00']);
    $mail = new LoanReceipt($loan);

    $mail->assertHasSubject('Your loan of '.$loan->book->title)
        ->assertSeeInText('Please return it by October 26, 2026.')
        ->assertHasTag('loan-receipt');

    [$ics] = $mail->attachments();
    expect($ics->as)->toBe('due-date.ics')->and($ics->mime)->toBe('text/calendar');
});

test('the receipt is translated', function () {
    $loan = Loan::factory()->create(['due_at' => '2026-10-26 12:00:00']);

    (new LoanReceipt($loan))->locale('es')->assertSeeInText('Devuélvelo antes del 26 de octubre de 2026.');
});

test('overdue notices go to the member and the desk', function () {
    Notification::fake();
    $loan = Loan::factory()->create(['due_at' => now()->subDays(9)]);

    $loan->user->notify(new LoanOverdue($loan));
    Notification::route('mail', 'desk@bookshelf.test')->notify(new LoanOverdue($loan));

    Notification::assertSentTo($loan->user, LoanOverdue::class, fn ($n, array $channels) => $channels === ['mail', 'database']);
    Notification::assertSentOnDemand(LoanOverdue::class, fn ($n, $channels, AnonymousNotifiable $to) => $to->routes['mail'] === 'desk@bookshelf.test');
});

All four pass. Notification::fake() does not call toMail(), so it would not have caught the $notifiable->name error. Rendering the notification for an AnonymousNotifiable in a test, or running a real queue in a smoke test, does.

Where older books and tutorials are outdated

  • build() with $this->view(...)->subject(...)->attach(...) (Stauffer ch. 15) still works. Laravel 9.35 split it into envelope(), content(), attachments() and headers().

  • Mailtrap or MailHog for local mail are replaced by Mailpit, which Sail includes. MailHog is unmaintained.

  • MAIL_DRIVER was renamed MAIL_MAILER in Laravel 7, when several mailers became possible.

  • SwiftMailer was replaced by Symfony Mailer in Laravel 9.

  • Calling mail() in a loop (Nixon, Powers) gives no queueing, retries, templates or tests.