Notifications and Mail — The Right Way

Notifications and Mail — The Right Way

Reading Time: 4 minutes

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 / LogMailSent listener pair
  • The ExceptionOccurred Mailable as a production pattern
  • Queued notifications

Notification vs. Mailable

Laravel provides two ways to send communications. They serve different purposes:

NotificationMailable
ChannelsEmail, SMS, database, Slack, push — any channelEmail only
TargetNotifiable model ($user->notify(...))Any email address
Use forUser-facing alerts that might go to multiple channelsSystem emails, transactional emails to arbitrary addresses
ExampleServiceRequestRejectedNotificationOrderConfirmationMailExceptionOccurredMail

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 background
  • via() returns multiple channels — the same notification object handles both
  • toDatabase() returns a simple array for the notifications table
  • 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 ShouldQueue on 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 / MessageSent listeners — it’s a free audit trail.
  • ExceptionOccurred Mailable is paired with ErrorReporter for rate-limited exception alerts.
  • The notifications table (via php artisan notifications:table) stores database channel notifications.

Tips and Gotchas

⚠️ Warning: toMail() returning a MailMessage sends 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 dedicated Mailable class. 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 the database channel right now. It costs nothing and means you can add DB notification storage later without touching the notification class.

🔥 Expert Note: The LogMailSending / LogMailSent listener pair gives you a complete mail audit log with zero changes to your Mailable classes. Attach it to Laravel’s mail events in EventServiceProvider and every email sent by every package is automatically logged.

Further Reading


← Exports and Reports | Next: GitHub Actions CI →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.