Notifications and Mail — The Right Way
Series: Every Laravel Project Should Have These Building Blocks
Part: 23 of 35 Level: Intermediate Prerequisites: Events and Listeners
What You’ll Learn
- Notification vs. Mailable — when to use each
- Building multi-channel Notifications properly
- Logging every mail send with the
LogMailSending/LogMailSentlistener pair - The
ExceptionOccurredMailable as a production pattern - Queued notifications
Notification vs. Mailable
Laravel provides two ways to send communications. They serve different purposes:
| Notification | Mailable | |
|---|---|---|
| Channels | Email, SMS, database, Slack, push — any channel | Email only |
| Target | A Notifiable model ($user->notify(...)) | Any email address |
| Use for | User-facing alerts that might go to multiple channels | System emails, transactional emails to arbitrary addresses |
| Example | ServiceRequestRejectedNotification | OrderConfirmationMail, ExceptionOccurredMail |
Use Notifications when the recipient is a model in your system and you might want to reach them through different channels. Use Mailables when you’re sending a specific email document (an invoice, a confirmation, a system alert).
A Clean Notification
// app/Notifications/ServiceRequestEtaChangedNotification.php
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\ServiceRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class ServiceRequestEtaChangedNotification extends Notification implements ShouldQueue
{
use Queueable;
public string $queue = 'notifications';
public function __construct(
public readonly ServiceRequest $serviceRequest,
public readonly string $previousEta,
) {}
public function via(object $notifiable): array
{
return ['mail', 'database'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("ETA Updated — Service Request #{$this->serviceRequest->id}")
->greeting("Hi {$notifiable->name},")
->line("The estimated completion date for your service request has been updated.")
->line("**Previous ETA:** {$this->previousEta}")
->line("**New ETA:** {$this->serviceRequest->eta->format('D, M j Y')}")
->action('View Service Request', route('serviceRequests.show', $this->serviceRequest))
->line('If you have questions, please contact your account manager.');
}
/**
* @return array<string, mixed>
*/
public function toDatabase(object $notifiable): array
{
return [
'service_request_id' => $this->serviceRequest->id,
'previous_eta' => $this->previousEta,
'new_eta' => $this->serviceRequest->eta->toDateString(),
'message' => "ETA updated for Service Request #{$this->serviceRequest->id}",
];
}
}
Key points:
implements ShouldQueue— notifications run in the backgroundvia()returns multiple channels — the same notification object handles bothtoDatabase()returns a simple array for thenotificationstable- Named queue (
'notifications') keeps these separate from heavy jobs
Sending Notifications
From a Listener or Action:
// From a listener
class NotifyCustomerOfEtaChange implements ShouldQueue
{
public function handle(ServiceRequestEtaChanged $event): void
{
$event->serviceRequest->requester->notify(
new ServiceRequestEtaChangedNotification(
$event->serviceRequest,
$event->previousEta,
)
);
}
}
// Or directly in an Action
$user->notify(new PasswordResetNotification($token));
A Clean Mailable
For emails not tied to a specific user model — order confirmations, invoices, system alerts:
// app/Mail/OrderConfirmationMail.php
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Order;
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 OrderConfirmationMail extends Mailable implements ShouldQueue
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly Order $order
) {}
public function envelope(): Envelope
{
return new Envelope(
to: $this->order->email,
subject: "Order Confirmed — #{$this->order->id}",
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.orders.confirmation',
);
}
}
The Blade template at resources/views/emails/orders/confirmation.blade.php:
@component('mail::message')
# Your Order Is Confirmed
Thank you for your order, {{ $order->customer_name }}.
**Order #{{ $order->id }}**
@component('mail::table')
| Item | Qty | Price |
|:-----|:----|------:|
@foreach($order->items as $item)
| {{ $item->name }} | {{ $item->quantity }} | {{ $item->price_formatted }} |
@endforeach
@endcomponent
**Total: {{ $order->total_formatted }}**
@component('mail::button', ['url' => route('orders.show', $order)])
View Order
@endcomponent
@endcomponent
The Mail Audit Log
One pattern that appears in every project: logging every email send attempt. When a customer says “I never got the email”, you need proof.
// app/Listeners/LogMailSending.php
class LogMailSending
{
public function handle(\Illuminate\Mail\Events\MessageSending $event): void
{
Log::channel('mail')->info('Sending mail', [
'to' => array_keys($event->message->getTo() ?? []),
'subject' => $event->message->getSubject(),
]);
}
}
// app/Listeners/LogMailSent.php
class LogMailSent
{
public function handle(\Illuminate\Mail\Events\MessageSent $event): void
{
Log::channel('mail')->info('Mail sent', [
'to' => array_keys($event->message->getTo() ?? []),
'subject' => $event->message->getSubject(),
]);
}
}
Register them in EventServiceProvider:
protected $listen = [
\Illuminate\Mail\Events\MessageSending::class => [LogMailSending::class],
\Illuminate\Mail\Events\MessageSent::class => [LogMailSent::class],
];
Every email now creates two log entries in logs/mail.log: one when the send starts, one when it succeeds. If an email was sent to SMTP but never delivered, this log proves the application did its job.
The ExceptionOccurred Mailable — System Alerts
This is a special-purpose Mailable used by the ErrorReporter service:
// app/Mail/ExceptionOccurred.php
class ExceptionOccurred extends Mailable
{
public function __construct(
private readonly array $payload
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "[{$this->payload['environment']}] {$this->payload['exception_class']}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.exception-occurred',
with: ['payload' => $this->payload],
);
}
}
The email view renders the full exception with:
- Exception class + message
- File + line number
- Stack trace (collapsible in HTML)
- Request URL + method
- Sanitized input (sensitive fields redacted)
- User ID, environment, request_id
This is not the nicest email, but it has everything you need to debug a production exception without SSH access.
Database Notifications
When via() returns 'database', Laravel writes to the notifications table. Create it:
php artisan notifications:table
php artisan migrate
Query unread notifications in controllers:
// Count unread
$user->unreadNotifications->count();
// Mark all read
$user->unreadNotifications->markAsRead();
// Get paginated
$user->notifications()->paginate(20);
Key Takeaways
- Notifications for user-facing, multi-channel alerts. Mailables for specific email documents (invoices, confirmations, system alerts).
- Always implement
ShouldQueueon Notifications — they run in the background. - The
via()method returns an array — one notification can go to email + database + SMS simultaneously. - Log every mail send with
MessageSending/MessageSentlisteners — it’s a free audit trail. ExceptionOccurredMailable is paired withErrorReporterfor rate-limited exception alerts.- The
notificationstable (viaphp artisan notifications:table) stores database channel notifications.
Tips and Gotchas
⚠️ Warning:
toMail()returning aMailMessagesends the email from the notification system — this is fine for simple mails. But if you need rich HTML templates, attachments, or BCC/CC control, switch to a dedicatedMailableclass. Notifications are for simple, multi-channel messages; Mailables are for complex email-only messages.
💡 Tip: Always implement
toArray()on notifications even if you don’t use thedatabasechannel right now. It costs nothing and means you can add DB notification storage later without touching the notification class.
🔥 Expert Note: The
LogMailSending/LogMailSentlistener pair gives you a complete mail audit log with zero changes to your Mailable classes. Attach it to Laravel’s mail events inEventServiceProviderand every email sent by every package is automatically logged.
Further Reading
- Laravel Docs: Notifications
- Laravel Docs: Mail
- Laravel Docs: Mail Events
- Mailtrap / Mailpit — local mail testing tools