Mail & Notifications Guide
Last Updated: 2026-01-08 Status: Active Audience: Developers
This guide documents mail and notification patterns for the Client Portal application.
Table of Contents
- Overview
- Mailable Classes
- Notification Classes
- Email Templates
- Notification Channels
- Testing
- Configuration
Overview
Laravel provides two systems for sending messages:
- Mailables: For sending emails directly
- Notifications: For sending messages across multiple channels
When to Use Each
| Use Case | System |
|---|---|
| Simple email only | Mailable |
| Multi-channel (email + database + SMS) | Notification |
| User-triggered messages | Notification |
| System/admin emails | Mailable |
| Queued messages | Either (both support queuing) |
Mailable Classes
Generate a Mailable
php artisan make:mail InvoiceMail
php artisan make:mail WelcomeMail --markdown=emails.welcome
Basic Mailable Structure
<?php
namespace App\Mail;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class InvoiceMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(
public Invoice $invoice,
public ?string $customMessage = null
) {}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: "Invoice #{$this->invoice->number} from " . config('app.name'),
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'emails.invoice',
with: [
'invoice' => $this->invoice,
'client' => $this->invoice->client,
'customMessage' => $this->customMessage,
],
);
}
/**
* Get the attachments for the message.
*/
public function attachments(): array
{
$attachments = [];
if ($this->invoice->pdf_path) {
$attachments[] = Attachment::fromStorage($this->invoice->pdf_path)
->as("invoice-{$this->invoice->number}.pdf")
->withMime('application/pdf');
}
return $attachments;
}
}
Sending Mailables
use App\Mail\InvoiceMail;
use Illuminate\Support\Facades\Mail;
// Send immediately
Mail::to($client->email)->send(new InvoiceMail($invoice));
// Queue for background sending
Mail::to($client->email)->queue(new InvoiceMail($invoice));
// Send later
Mail::to($client->email)
->later(now()->addMinutes(5), new InvoiceMail($invoice));
// Multiple recipients
Mail::to($client->email)
->cc($admin->email)
->bcc(config('client_portal.admin_email'))
->send(new InvoiceMail($invoice));
Mailable with Dynamic Subject
public function envelope(): Envelope
{
$subject = match ($this->invoice->status) {
InvoiceStatus::Sent => "Invoice #{$this->invoice->number}",
InvoiceStatus::Overdue => "REMINDER: Invoice #{$this->invoice->number} is Overdue",
InvoiceStatus::Paid => "Thank You - Invoice #{$this->invoice->number} Paid",
default => "Invoice #{$this->invoice->number}",
};
return new Envelope(
subject: $subject,
replyTo: [config('client_portal.support_email')],
);
}
Notification Classes
Generate a Notification
php artisan make:notification InvoicePaidNotification
php artisan make:notification ProjectCompletedNotification
Basic Notification Structure
<?php
namespace App\Notifications;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InvoicePaidNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Invoice $invoice
) {}
/**
* Get the notification's delivery channels.
*/
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Payment Received - Invoice #{$this->invoice->number}")
->greeting("Hello {$notifiable->name},")
->line("We've received your payment for Invoice #{$this->invoice->number}.")
->line("Amount: $" . number_format($this->invoice->total, 2))
->action('View Invoice', route('client.invoices.show', $this->invoice))
->line('Thank you for your business!');
}
/**
* Get the array representation for database storage.
*/
public function toArray(object $notifiable): array
{
return [
'invoice_id' => $this->invoice->id,
'invoice_number' => $this->invoice->number,
'amount' => $this->invoice->total,
'message' => "Payment received for Invoice #{$this->invoice->number}",
];
}
}
Sending Notifications
use App\Notifications\InvoicePaidNotification;
// Send to a single user
$user->notify(new InvoicePaidNotification($invoice));
// Send to multiple users
Notification::send($users, new InvoicePaidNotification($invoice));
// Send to non-user (on-demand)
Notification::route('mail', 'admin@example.com')
->notify(new InvoicePaidNotification($invoice));
// Multiple on-demand routes
Notification::route('mail', 'admin@example.com')
->route('slack', 'webhook-url')
->notify(new InvoicePaidNotification($invoice));
Notification with Conditional Channels
public function via(object $notifiable): array
{
$channels = ['database'];
if ($notifiable->email_notifications) {
$channels[] = 'mail';
}
if ($notifiable->sms_notifications && $this->invoice->isOverdue()) {
$channels[] = 'vonage';
}
return $channels;
}
Email Templates
Markdown Email Template
{{-- resources/views/emails/invoice.blade.php --}}
<x-mail::message>
# Invoice {{ $invoice->number }}
@if ($customMessage)
{{ $customMessage }}
---
@endif
Hello {{ $client->name }},
Please find your invoice details below:
<x-mail::table>
| Description | Quantity | Price |
|:------------|:--------:|------:|
@foreach ($invoice->items as $item)
| {{ $item->description }} | {{ $item->quantity }} | ${{ number_format($item->total, 2) }} |
@endforeach
</x-mail::table>
**Subtotal:** ${{ number_format($invoice->subtotal, 2) }}
**Tax ({{ $invoice->tax_rate }}%):** ${{ number_format($invoice->tax_amount, 2) }}
**Total:** ${{ number_format($invoice->total, 2) }}
**Due Date:** {{ $invoice->due_date->format('F j, Y') }}
<x-mail::button :url="$invoice->payment_url">
Pay Now
</x-mail::button>
Thank you for your business!
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
Custom Mail Components
# Publish mail components for customization
php artisan vendor:publish --tag=laravel-mail
{{-- resources/views/vendor/mail/html/header.blade.php --}}
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
<img src="{{ asset('images/logo.png') }}" alt="{{ config('app.name') }}" height="40">
</a>
</td>
</tr>
Plain Text Emails
public function content(): Content
{
return new Content(
markdown: 'emails.invoice',
text: 'emails.invoice-text', // Plain text version
);
}
{{-- resources/views/emails/invoice-text.blade.php --}}
Invoice {{ $invoice->number }}
Hello {{ $client->name }},
Invoice Details:
@foreach ($invoice->items as $item)
- {{ $item->description }}: ${{ number_format($item->total, 2) }}
@endforeach
Total: ${{ number_format($invoice->total, 2) }}
Due Date: {{ $invoice->due_date->format('F j, Y') }}
Pay at: {{ $invoice->payment_url }}
Thanks,
{{ config('app.name') }}
Notification Channels
Database Channel
Requires the notifications table:
php artisan notifications:table
php artisan migrate
// In User model (or any notifiable model)
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
}
// Accessing notifications
$user->notifications;
$user->unreadNotifications;
$user->readNotifications;
// Mark as read
$user->unreadNotifications->markAsRead();
$notification->markAsRead();
// Delete old notifications
$user->notifications()->where('created_at', '<', now()->subMonths(3))->delete();
Displaying Notifications
{{-- In a dropdown or sidebar --}}
@foreach (auth()->user()->unreadNotifications as $notification)
<div class="notification {{ $notification->read_at ? 'read' : 'unread' }}">
<p>{{ $notification->data['message'] }}</p>
<small>{{ $notification->created_at->diffForHumans() }}</small>
@if ($notification->data['invoice_id'] ?? null)
<a href="{{ route('invoices.show', $notification->data['invoice_id']) }}">
View Invoice
</a>
@endif
</div>
@endforeach
Custom Channel
<?php
namespace App\Channels;
use Illuminate\Notifications\Notification;
class WebhookChannel
{
public function send(object $notifiable, Notification $notification): void
{
$data = $notification->toWebhook($notifiable);
Http::post($notifiable->webhook_url, $data);
}
}
// In notification class
public function via(object $notifiable): array
{
return ['mail', WebhookChannel::class];
}
public function toWebhook(object $notifiable): array
{
return [
'event' => 'invoice.paid',
'invoice_id' => $this->invoice->id,
'amount' => $this->invoice->total,
];
}
Testing
Testing Mailables
use App\Mail\InvoiceMail;
use Illuminate\Support\Facades\Mail;
public function test_invoice_email_contains_invoice_details(): void
{
$invoice = Invoice::factory()->create();
$mailable = new InvoiceMail($invoice);
$mailable->assertHasSubject("Invoice #{$invoice->number} from " . config('app.name'));
$mailable->assertSeeInHtml($invoice->number);
$mailable->assertSeeInHtml(number_format($invoice->total, 2));
$mailable->assertSeeInHtml($invoice->client->name);
}
public function test_invoice_email_has_pdf_attachment(): void
{
$invoice = Invoice::factory()->create(['pdf_path' => 'invoices/test.pdf']);
Storage::fake('local');
Storage::put('invoices/test.pdf', 'pdf content');
$mailable = new InvoiceMail($invoice);
$mailable->assertHasAttachedData('pdf content', "invoice-{$invoice->number}.pdf");
}
Testing Mail is Sent
use Illuminate\Support\Facades\Mail;
public function test_invoice_email_sent_on_send_action(): void
{
Mail::fake();
$invoice = Invoice::factory()->create();
$this->actingAs($admin)
->post(route('admin.invoices.send', $invoice));
Mail::assertSent(InvoiceMail::class, function ($mail) use ($invoice) {
return $mail->invoice->id === $invoice->id;
});
Mail::assertSent(InvoiceMail::class, function ($mail) use ($invoice) {
return $mail->hasTo($invoice->client->email);
});
}
public function test_invoice_email_queued(): void
{
Mail::fake();
$invoice = Invoice::factory()->create();
Mail::to($invoice->client->email)->queue(new InvoiceMail($invoice));
Mail::assertQueued(InvoiceMail::class);
}
Testing Notifications
use Illuminate\Support\Facades\Notification;
use App\Notifications\InvoicePaidNotification;
public function test_user_notified_when_invoice_paid(): void
{
Notification::fake();
$invoice = Invoice::factory()->create();
$user = $invoice->client->user;
// Trigger payment
$invoice->markAsPaid();
Notification::assertSentTo(
$user,
InvoicePaidNotification::class,
function ($notification) use ($invoice) {
return $notification->invoice->id === $invoice->id;
}
);
}
public function test_notification_stored_in_database(): void
{
$user = User::factory()->create();
$invoice = Invoice::factory()->create();
$user->notify(new InvoicePaidNotification($invoice));
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $user->id,
'notifiable_type' => User::class,
'type' => InvoicePaidNotification::class,
]);
$notification = $user->notifications->first();
$this->assertEquals($invoice->id, $notification->data['invoice_id']);
}
Testing Notification Content
public function test_notification_mail_content(): void
{
$invoice = Invoice::factory()->create();
$user = $invoice->client->user;
$notification = new InvoicePaidNotification($invoice);
$mail = $notification->toMail($user);
$this->assertStringContainsString($invoice->number, $mail->render());
$this->assertStringContainsString(
route('client.invoices.show', $invoice),
$mail->render()
);
}
Configuration
Mail Configuration
// config/mail.php
return [
'default' => env('MAIL_MAILER', 'smtp'),
'mailers' => [
'smtp' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST', 'smtp.mailgun.org'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME'),
'password' => env('MAIL_PASSWORD'),
'timeout' => null,
],
'log' => [
'transport' => 'log',
'channel' => env('MAIL_LOG_CHANNEL'),
],
],
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'noreply@example.com'),
'name' => env('MAIL_FROM_NAME', 'Client Portal'),
],
];
Environment Variables
# Development
MAIL_MAILER=log
# Production
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
MAIL_USERNAME=postmaster@mg.yourdomain.com
MAIL_PASSWORD=your-password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="${APP_NAME}"
Notification Preferences
// In User model
public function routeNotificationForMail(): string
{
return $this->email;
}
public function routeNotificationForVonage(): string
{
return $this->phone_number;
}
// With notification preferences
public function shouldReceiveNotification(string $type): bool
{
return $this->notification_preferences[$type] ?? true;
}
Common Patterns
Invoice Reminder Notification
<?php
namespace App\Notifications;
use App\Models\Invoice;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class InvoiceReminderNotification extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(
public Invoice $invoice,
public int $daysUntilDue
) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
$message = (new MailMessage)
->subject($this->getSubject());
if ($this->daysUntilDue < 0) {
$message->error();
}
return $message
->greeting("Hello {$notifiable->name},")
->line($this->getMessage())
->line("Amount Due: $" . number_format($this->invoice->total, 2))
->action('Pay Now', route('client.invoices.pay', $this->invoice))
->line('Please contact us if you have any questions.');
}
private function getSubject(): string
{
return match (true) {
$this->daysUntilDue < 0 => "OVERDUE: Invoice #{$this->invoice->number}",
$this->daysUntilDue === 0 => "Due Today: Invoice #{$this->invoice->number}",
$this->daysUntilDue <= 3 => "Reminder: Invoice #{$this->invoice->number} Due Soon",
default => "Upcoming: Invoice #{$this->invoice->number}",
};
}
private function getMessage(): string
{
return match (true) {
$this->daysUntilDue < 0 => "Invoice #{$this->invoice->number} is " . abs($this->daysUntilDue) . " days overdue.",
$this->daysUntilDue === 0 => "Invoice #{$this->invoice->number} is due today.",
default => "Invoice #{$this->invoice->number} is due in {$this->daysUntilDue} days.",
};
}
public function toArray(object $notifiable): array
{
return [
'invoice_id' => $this->invoice->id,
'invoice_number' => $this->invoice->number,
'days_until_due' => $this->daysUntilDue,
'message' => $this->getMessage(),
];
}
}
Welcome Email
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class WelcomeMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public User $user,
public string $temporaryPassword
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Welcome to ' . config('app.name'),
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.welcome',
with: [
'loginUrl' => route('login'),
'user' => $this->user,
'password' => $this->temporaryPassword,
],
);
}
}
Best Practices
Do
- Use queued mail/notifications for better performance
- Create separate notification classes for each event type
- Include both HTML and plain text versions
- Test email content and delivery
- Use markdown templates for consistency
- Provide unsubscribe links for marketing emails
- Log email sending for debugging
Don't
- Send emails synchronously in web requests
- Include sensitive data in email subjects
- Hardcode email addresses in code
- Forget to handle notification preferences
- Send duplicate notifications
- Use mail for time-sensitive alerts only