Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Backend Features/Mail & Notifications

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

  1. Overview
  2. Mailable Classes
  3. Notification Classes
  4. Email Templates
  5. Notification Channels
  6. Testing
  7. 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 CaseSystem
Simple email onlyMailable
Multi-channel (email + database + SMS)Notification
User-triggered messagesNotification
System/admin emailsMailable
Queued messagesEither (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