Laravel Tutorial · Lessons

Foundation

Basic

Medium

Advanced

Mail & Notifications

Mailables, Markdown mail, notification channels and previewing emails.

"You've been assigned a task", "Your password was changed", "Your invoice is ready": sending messages to users is one of the most common jobs in any app. Laravel has two tools for it:

  • Mailables are a class per email. You use them when the message is an email.
  • Notifications are a class per event ("task assigned"), which can be delivered over mail, the database, Slack, SMS and more.

This app has one of each for the Projects & Tasks demo: TaskAssigned and TaskAssignedNotification. Both are real, and both are rendered live below without ever being sent.

Try it: preview a real email

Pick a task. The preview is produced by GET /api/demo/tasks/{id}/mail-preview, which calls render() on the Mailable. You see exactly the HTML a user's inbox would receive.

Writing a Mailable

Generate one with a Markdown template. Laravel creates both the class and the view:

php artisan make:mail TaskAssigned --markdown=mail.task-assigned

app/Mail/TaskAssigned.php

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

    /**
     * Create a new message instance.
     */
    public function __construct(public Task $task) {}

    /**
     * Get the message envelope.
     */
    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "You've been assigned: {$this->task->title}",
        );
    }

    /**
     * Get the message content definition.
     */
    public function content(): Content
    {
        return new Content(
            markdown: 'mail.task-assigned',
            with: [
                'url' => route('api.demo.projects.show', $this->task->project),
            ],
        );
    }
}

There are three parts:

  • Constructor. Public properties such as $task are automatically available in the view.
  • envelope() sets the subject, from, reply-to, cc and tags.
  • content() names the view and passes any extra data.

implements ShouldQueue means Mail::send() will queue the email instead of making the user wait while it's sent. SerializesModels stores only the task's id in the queue and re-fetches it when the job runs.

Markdown mail templates

Markdown mail uses ready-made components (button, panel, table) that render as responsive, email-client-safe HTML, and also generate a plain-text version:

resources/views/mail/task-assigned.blade.php

<x-mail::message>
# Hi {{ $task->assignee?->name ?? 'there' }},

You've been assigned a new task in **{{ $task->project->name }}**.

<x-mail::panel>
**{{ $task->title }}**<br>
Priority: {{ $task->priority->label() }}<br>
Due: {{ $task->due_on?->toFormattedDateString() ?? 'No due date' }}
</x-mail::panel>

<x-mail::button :url="$url">
View project
</x-mail::button>

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

Don't indent Markdown mail templates. Markdown treats four spaces of indentation as a code block, so an indented paragraph shows up as monospace text in the email.

To change colours and the header logo, run php artisan vendor:publish --tag=laravel-mail. It copies the components into resources/views/vendor/mail for you to edit.

Sending mail

Illustrative — the demo never sends anything:

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

Mail::to($task->assignee)->send(new TaskAssigned($task));   // queued, because of ShouldQueue
Mail::to($task->assignee)->queue(new TaskAssigned($task));  // always queued
Mail::to('ops@example.com')
    ->cc($project->owner)
    ->later(now()->addMinutes(10), new TaskAssigned($task)); // queued with a delay

Mail::to() accepts an email string, a model with an email attribute, or a collection of users.

Where mail goes is set by MAIL_MAILER in .env, so the same code works everywhere:

Mailer Use it for
log Local development: messages are written to storage/logs/laravel.log (this app's local setting)
smtp Any SMTP server or provider
ses, postmark, resend, mailgun Provider APIs (faster and more reliable than SMTP)
array Tests: messages are held in memory
failover / roundrobin Try several mailers in order, or spread the load

Previewing without sending

render() turns a Mailable into its final HTML, with no mail server involved. That's all this app's preview endpoint does:

app/Http/Controllers/Api/MailPreviewController.php

public function __invoke(Request $request, Task $task): Response|JsonResponse
{
    $task->load(['project', 'assignee']);

    if ($request->query('as') === 'notification') {
        abort_unless($task->assignee, 422, 'This task has no assignee to notify.');

        $notification = new TaskAssignedNotification($task);

        /** @var MailMessage $message */
        $message = $notification->toMail($task->assignee);

        return response()->json([
            'channels' => $notification->via($task->assignee),
            'subject' => $message->subject,
            'array' => $notification->toArray($task->assignee),
            'html' => (string) $message->render(),
        ]);
    }

    $mailable = new TaskAssigned($task);

    return response($mailable->render())
        ->header('X-Mail-Subject', $mailable->envelope()->subject);
}

During development you can get the same preview by returning a Mailable straight from a route, because Mailables are "renderable": Route::get('/mail', fn () => new TaskAssigned(Task::first()));. Keep that route out of production, since it would expose real data.

Notifications: one event, many channels

A Notification describes what happened and lets each channel format it. Here is this app's version of "task assigned":

app/Notifications/TaskAssignedNotification.php

public function via(object $notifiable): array
{
    return ['mail'];
}

/**
 * Get the mail representation of the notification.
 */
public function toMail(object $notifiable): MailMessage
{
    return (new MailMessage)
        ->subject("New task: {$this->task->title}")
        ->greeting("Hi {$notifiable->name},")
        ->line("You've been assigned \"{$this->task->title}\" in {$this->task->project->name}.")
        ->line('Priority: '.$this->task->priority->label())
        ->action('View project', route('api.demo.projects.show', $this->task->project))
        ->line('Thanks for keeping things moving!');
}

/**
 * Get the array representation of the notification.
 *
 * @return array<string, mixed>
 */
public function toArray(object $notifiable): array
{
    return [
        'task_id' => $this->task->id,
        'title' => $this->task->title,
        'project' => $this->task->project->name,
        'priority' => $this->task->priority->value,
    ];
}

MailMessage builds a tidy email from lines and one button, with no template file needed. toArray() is the payload for the database and broadcast channels. Switch the demo at the top of this page to Notification to see both outputs for one task, served by GET /api/demo/tasks/{id}/mail-preview?as=notification.

Send a notification to any model that uses the Notifiable trait. User does by default. Illustrative:

$task->assignee->notify(new TaskAssignedNotification($task));

// Or many users at once: any collection of notifiable models
$watchers = User::whereKey($watcherIds)->get();
Notification::send($watchers, new TaskAssignedNotification($task));

via() can decide per user, for example return $notifiable->prefers_sms ? ['vonage'] : ['mail', 'database'];.

In-app notifications with the database channel

The "bell icon" list in most apps is the database channel. Run php artisan make:notifications-table and migrate, add 'database' to via(), and read the notifications back. Illustrative — this app doesn't create the notifications table:

$user->unreadNotifications;              // a collection of DatabaseNotification models
$user->unreadNotifications->markAsRead();

foreach ($user->notifications as $notification) {
    echo $notification->data['title'];   // the array from toArray()
}

Sending at the right moment

Send mail where the event happens. The cleanest place is usually an event listener, so the controller doesn't need to know who gets told. This app already fires TaskCompleted (see Queues, Jobs & Events), and "task assigned" would follow the same pattern:

// In a listener for a TaskAssigned event (illustrative)
public function handle(TaskAssignedEvent $event): void
{
    $event->task->assignee?->notify(new TaskAssignedNotification($event->task));
}

Testing mail and notifications

Fake the facade, run the code, then assert what would have been sent. These are real tests from this app:

tests/Feature/Demo/DemoExtrasApiTest.php (excerpt)

public function test_mail_preview_renders_without_sending(): void
{
    Mail::fake();
    Notification::fake();
    $task = Task::factory()->create(['title' => 'Ship the release']);

    $this->get(route('api.demo.tasks.mail-preview', $task))
        ->assertOk()
        ->assertSee('Ship the release')
        ->assertSee($task->project->name)
        ->assertHeader('X-Mail-Subject', "You've been assigned: Ship the release");

    // ...

    Mail::assertNothingSent();
    Mail::assertNothingQueued();
    Notification::assertNothingSent();
}

public function test_task_assigned_mailable_content(): void
{
    $task = Task::factory()->highPriority()->for(Project::factory()->create(['name' => 'Apollo']))->create();

    $mailable = new TaskAssigned($task);

    $mailable->assertHasSubject("You've been assigned: {$task->title}");
    $mailable->assertSeeInHtml('Apollo');
    $mailable->assertSeeInHtml('High');
}

In code that does send, the usual assertions are Mail::assertQueued(TaskAssigned::class, fn ($mail) => $mail->hasTo($user->email)) and Notification::assertSentTo($user, TaskAssignedNotification::class).

Key takeaways

  • A Mailable is one email: envelope() for the subject and addresses, content() for the view. Markdown templates give you responsive HTML and plain text for free.
  • Add ShouldQueue so sending never slows down a request. MAIL_MAILER chooses the transport (log locally, a provider in production).
  • render() previews any Mailable without sending it. That's how this page shows real emails safely.
  • A Notification describes an event and formats it per channel (toMail, toArray for the database and broadcast channels). Send it with $user->notify().
  • Test with Mail::fake() and Notification::fake(), plus the Mailable's own assertHasSubject() and assertSeeInHtml().