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}"
Source: Laravel — Mail Configuration
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')];
}
}
Source: Laravel — Writing Mailables and Attachments
{{-- 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>
Source: Laravel — Markdown Mailables
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
Source: Laravel — Queueing Mail and Localizing Mailables
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
Source: Laravel — Tags and Metadata
The output shows several things to check before going live:
-
From: Laravel.MAIL_FROM_NAMEdefaults to${APP_NAME}, and Bookshelf’sAPP_NAMEwas stillLaravel. The name also appears in the layout’s header and footer. -
The footer is not translated. The strings that
lang/es.jsonlists were translated, and the date was formatted in Spanish by Carbon’sisoFormat('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-TagandX-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'));
Source: Laravel — Failover Configuration
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
Source: Laravel — Failover Configuration
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 anAnonymousNotifiablewith routes only:ErrorException: Undefined property: Illuminate\Notifications\AnonymousNotifiable::$name. After theissetguard, the desk received its notice. Thedatabasejob 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');
});
Source: Laravel — Testing Mailables and Notifications Testing
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 intoenvelope(),content(),attachments()andheaders(). -
Mailtrap or MailHog for local mail are replaced by Mailpit, which Sail includes. MailHog is unmaintained.
-
MAIL_DRIVERwas renamedMAIL_MAILERin 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.