Currently Available: Need a skilled Software Developer for your next project?
Categories
Laravel

Customizing Laravel’s Email Notifications

Laravel comes with a powerful notification system that handles various automated emails like password resets, email verifications, and team invitations. If you're using Laravel Fortify or Jetstream for authentication and team management, these packages trigger these notifications automatically.

Let's customize how these emails look and what they say.

Overview of Laravel Notifications

There are two main aspects you might want to customize in Laravel's notification emails:

  1. Content: The actual text, buttons, and structure of your emails
  2. Layout: The visual design, including headers, footers, and styling

You can customize either one independently - you don't need to change both if you don't want to. Let's start with content customization.

Part 1: Customizing Email Content

Creating Custom Notification Classes

First, let's create our custom notification classes. Laravel provides artisan commands to make this easy:

# Create notification classes
php artisan make:notification CustomVerifyEmail
php artisan make:notification CustomResetPassword

Now modify these files:

File: app/Notifications/CustomVerifyEmail.php

<?php

namespace App\Notifications;

use Illuminate\Auth\Notifications\VerifyEmail as BaseVerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;

class CustomVerifyEmail extends BaseVerifyEmail
{
    protected function buildMailMessage($url)
    {
        return (new MailMessage)
            ->subject('Welcome to ' . config('app.name'))
            ->greeting('Welcome aboard!')
            ->line('We\'re excited to have you join us. Just one more step - please verify your email address.')
            ->action('Verify Email Address', $url)
            ->line('After verification, you\'ll have full access to all our features.')
            ->line('If you did not create an account, no further action is required.');
    }
}

File: app/Notifications/CustomResetPassword.php

<?php

namespace App\Notifications;

use Illuminate\Auth\Notifications\ResetPassword as BaseResetPassword;
use Illuminate\Notifications\Messages\MailMessage;

class CustomResetPassword extends BaseResetPassword
{
    protected function buildMailMessage($url)
    {
        return (new MailMessage)
            ->subject('Password Reset Request')
            ->greeting('Hi there!')
            ->line('Forgot your password? No problem!')
            ->line('Click the button below to reset it.')
            ->action('Reset Password', $url)
            ->line('This link will expire in :count minutes.', 
                ['count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')])
            ->line('If you didn\'t request this, you can safely ignore this email.');
    }
}

Customizing Team Invitations (Jetstream)

For team invitations in Laravel Jetstream, instead of creating a notification class, we'll modify the email template:

File: resources/views/emails/team-invitation.blade.php

@component('mail::message')
# Join Our Team! 🎉

{{ __('You have been invited to join the :team team!', ['team' => $invitation->team->name]) }}

@if (Laravel\Fortify\Features::enabled(Laravel\Fortify\Features::registration()))
{{ __('If you do not have an account, you may create one by clicking the button below. After creating an account, you may click the invitation acceptance button in this email to accept the team invitation:') }}

@component('mail::button', ['url' => route('register')])
{{ __('Create Account') }}
@endcomponent

{{ __('If you already have an account, you may accept this invitation by clicking the button below:') }}
@else
{{ __('You may accept this invitation by clicking the button below:') }}
@endif

@component('mail::button', ['url' => $acceptUrl])
{{ __('Accept Invitation') }}
@endcomponent

{{ __('We\'re excited to have you join the team!') }}

{{ __('If you did not expect to receive an invitation to this team, you may discard this email.') }}
@endcomponent

Using the Custom Notifications

Update your User model to use these notifications:

File: app/Models/User.php

<?php

namespace App\Models;

use App\Notifications\CustomVerifyEmail;
use App\Notifications\CustomResetPassword;

class User extends Authenticatable
{
    public function sendEmailVerificationNotification()
    {
        $this->notify(new CustomVerifyEmail);
    }

    public function sendPasswordResetNotification($token)
    {
        $this->notify(new CustomResetPassword($token));
    }
}

Adding Localization (Optional)

If your app supports multiple languages, you can localize your notification content. First, create the language files:

touch lang/en/mail.php

Add translations to lang/en/mail.php:

<?php

return [
    'verify_email' => [
        'subject' => 'Welcome to :app_name',
        'greeting' => 'Welcome aboard!',
        'intro' => 'We\'re excited to have you join us. Just one more step - please verify your email address.',
        'action' => 'Verify Email Address',
        'outro' => 'After verification, you\'ll have full access to all our features.',
    ],
    // ... other translations
];

Repeat this for each language you support.

Then update your notification classes to use translations:

protected function buildMailMessage($url)
{
    return (new MailMessage)
        ->subject(__('mail.verify_email.subject', ['app_name' => config('app.name')]))
        ->greeting(__('mail.verify_email.greeting'))
        ->line(__('mail.verify_email.intro'))
        ->action(__('mail.verify_email.action'), $url)
        ->line(__('mail.verify_email.outro'));
}

User Preferred Locales

Your application needs to store each user's preferred locale, e. g. in a locale column in the users table (you need to do that yourself). If you have this, Laravel provides an elegant solution through the HasLocalePreference contract. By implementing this interface on your User model, Laravel will automatically use the stored locale when sending notifications and mailables to that user.

Here's how to implement the HasLocalePreference interface on your User model:

use Illuminate\Contracts\Translation\HasLocalePreference;

class User extends Model implements HasLocalePreference
{
    /**
     * Get the user's preferred locale.
     */
    public function preferredLocale(): string
    {
        return $this->locale;
    }
}

Once implemented, Laravel will automatically use the user's preferred locale when sending notifications and mailables to that model. No additional configuration is required.

Part 2: Customizing Email Layout

If you want to change how your emails look, you'll need to publish Laravel's mail templates:

php artisan vendor:publish --tag=laravel-mail

This creates the directory resources/views/vendor/mail/ with all the email templates.

Ready-to-Use Layout Examples

Here is a practical layout example you can use right away:

File: resources/views/vendor/mail/html/themes/default.css

/* Modern Business Theme */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
    box-sizing: border-box;
    font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
    position: relative;
}
body {
    -webkit-text-size-adjust: none;
    background-color: #f3f4f6;
    color: #4b5563;
    height: 100%;
    line-height: 1.5;
    margin: 0;
    padding: 0;
    width: 100% !important;
}
p, ul, ol, blockquote {
    line-height: 1.6;
    text-align: left;
}
a {
    color: #4f46e5;
    text-decoration: none;
    transition: color 0.2s ease-in-out;
}
a:hover {
    color: #6366f1;
}
a img {
    border: none;
}
h1 {
    color: #111827;
    font-size: 24px;
    font-weight: 700;
    margin-top: 0;
    text-align: left;
    letter-spacing: -0.025em;
}
h2 {
    color: #1f2937;
    font-size: 20px;
    font-weight: 600;
    margin-top: 0;
    text-align: left;
}
h3 {
    color: #374151;
    font-size: 16px;
    font-weight: 600;
    margin-top: 0;
    text-align: left;
}
p {
    font-size: 16px;
    line-height: 1.6;
    margin-top: 0;
    text-align: left;
    color: #4b5563;
}
p.sub {
    font-size: 13px;
    color: #6b7280;
}
img {
    max-width: 100%;
    border-radius: 8px;
}
.wrapper {
    -premailer-cellpadding: 0;
    -premailer-cellspacing: 0;
    -premailer-width: 100%;
    background-color: #f3f4f6;
    margin: 0;
    padding: 24px;
    width: 100%;
}
.content {
    -premailer-cellpadding: 0;
    -premailer-cellspacing: 0;
    -premailer-width: 100%;
    margin: 0;
    padding: 0;
    width: 100%;
}
.header {
    padding: 32px 0;
    text-align: center;
    border-radius: 12px 12px 0 0;
}
.header a {
    color: #ffffff;
    font-size: 22px;
    font-weight: 700;
    text-decoration: none;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.logo {
    height: 80px;
    max-height: 80px;
    width: auto;
    filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
}
.body {
    -premailer-cellpadding: 0;
    -premailer-cellspacing: 0;
    -premailer-width: 100%;
    background-color: #f3f4f6;
    margin: 0;
    padding: 0;
    width: 100%;
}
.inner-body {
    -premailer-cellpadding: 0;
    -premailer-cellspacing: 0;
    -premailer-width: 570px;
    background-color: #ffffff;
    border-radius: 12px;
    box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
    margin: 0 auto;
    padding: 0;
    width: 570px;
}
.subcopy {
    border-top: 1px solid #e5e7eb;
    margin-top: 32px;
    padding-top: 32px;
}
.subcopy p {
    font-size: 14px;
    color: #6b7280;
}
.footer {
    -premailer-cellpadding: 0;
    -premailer-cellspacing: 0;
    -premailer-width: 570px;
    margin: 24px auto 0;
    padding: 24px;
    text-align: center;
    width: 570px;
}
.footer p {
    color: #9ca3af;
    font-size: 13px;
    text-align: center;
}
.footer a {
    color: #6b7280;
    text-decoration: none;
    border-bottom: 1px dotted #9ca3af;
}
.table table {
    -premailer-cellpadding: 0;
    -premailer-cellspacing: 0;
    -premailer-width: 100%;
    margin: 30px auto;
    width: 100%;
    border-radius: 8px;
    overflow: hidden;
}
.table th {
    background-color: #f9fafb;
    border-bottom: 1px solid #e5e7eb;
    margin: 0;
    padding: 12px;
    color: #374151;
}
.table td {
    color: #4b5563;
    font-size: 15px;
    line-height: 1.5;
    margin: 0;
    padding: 12px;
}
.content-cell {
    max-width: 100vw;
    padding: 32px;
}
.action {
    -premailer-cellpadding: 0;
    -premailer-cellspacing: 0;
    -premailer-width: 100%;
    margin: 36px auto;
    padding: 0;
    text-align: center;
    width: 100%;
    float: unset;
}
.button {
    -webkit-text-size-adjust: none;
    border-radius: 8px;
    color: #ffffff;
    display: inline-block;
    overflow: hidden;
    text-decoration: none;
    transition: all 0.2s ease-in-out;
    font-weight: 600;
}
.button:hover {
    transform: translateY(-1px);
}
.button-blue, .button-primary {
    background: linear-gradient(135deg, #4f46e5 0%, #6366f1 100%);
    border: 0;
    padding: 12px 24px;
    box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.2);
}
.button-green, .button-success {
    background: linear-gradient(135deg, #059669 0%, #10b981 100%);
    border: 0;
    padding: 12px 24px;
    box-shadow: 0 4px 6px -1px rgba(5, 150, 105, 0.2);
}
.button-red, .button-error {
    background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
    border: 0;
    padding: 12px 24px;
    box-shadow: 0 4px 6px -1px rgba(220, 38, 38, 0.2);
}
.panel {
    border-left: #4f46e5 solid 4px;
    margin: 24px 0;
    border-radius: 0 8px 8px 0;
}
.panel-content {
    background-color: #f3f4f6;
    color: #4b5563;
    padding: 16px;
    border-radius: 0 8px 8px 0;
}
.panel-content p {
    color: #4b5563;
}
.panel-item {
    padding: 0;
}
.panel-item p:last-of-type {
    margin-bottom: 0;
    padding-bottom: 0;
}
.break-all {
    word-break: break-all;
}

Custom Header and Footer

File: resources/views/vendor/mail/html/message.blade.php

<x-mail::layout>
    <x-slot:header>
        <x-mail::header :url="config('app.url')">
            <img src="{{ asset('images/logo.png') }}" 
                 class="logo" 
                 alt="{{ config('app.name') }}">
        </x-mail::header>
    </x-slot:header>

    {{ $slot }}

    <x-slot:footer>
        <x-mail::footer>
            © {{ date('Y') }} {{ config('app.name') }}
            <br>
            <div class="social-links">
                <a href="https://twitter.com/yourcompany">Twitter</a> • 
                <a href="https://facebook.com/yourcompany">Facebook</a>
            </div>
            <br>
            <small>
                You received this email because you're a member of {{ config('app.name') }}.
                <br>
                {!! __('mail.footer.address') !!}
            </small>
        </x-mail::footer>
    </x-slot:footer>
</x-mail::layout>

Testing Your Changes

Create a testing command:

php artisan make:command TestNotifications

File: app/Console/Commands/TestNotifications.php

<?php

namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;

class TestNotifications extends Command
{
    protected $signature = 'test:notifications {user} {--all} {--verify} {--reset} {--team}';
    protected $description = 'Test email notifications for a user';

    public function handle()
    {
        $user = User::findOrFail($this->argument('user'));

        if ($this->option('all') || $this->option('verify')) {
            $this->info('Sending verification email...');
            $user->sendEmailVerificationNotification();
        }

        if ($this->option('all') || $this->option('reset')) {
            $this->info('Sending password reset email...');
            $user->sendPasswordResetNotification('test-token');
        }

        if ($this->option('all') || $this->option('team')) {
            $this->info('Sending team invitation email...');
            // For team invitations, we'll create a test invitation
            $team = $user->ownedTeams()->first();
            if ($team) {
                $invitation = $team->teamInvitations()->create([
                    'email' => 'test@example.com',
                    'role' => 'member'
                ]);
                // The invitation email will be sent automatically
                $this->info('Team invitation created and sent');
            } else {
                $this->error('User has no teams to test team invitations.');
            }
        }

        $this->info('Done! Check your email logs.');
    }
}

For development, set your mail driver to log in .env:

MAIL_MAILER=log

This will save emails to storage/logs/laravel.log instead of sending them.

Run the command:

php artisan test:notifications 1 --verify

Common Issues and Solutions

  1. Images not showing: Use absolute URLs in email templates:

    <img src="{{ url('images/logo.png') }}" alt="Logo">
  2. Styles not updating: Clear your view cache:

    php artisan view:clear
  3. Localization not working: Make sure you're setting the locale before sending:

    App::setLocale($user->locale);
    $user->notify(new CustomVerifyEmail);
What I'm building

Delegate tasks. Get software.

Give Vroni a GitHub issue, bug report, spec, or rough idea. It reads the repo, plans the change, writes code, runs checks, and works toward a review-ready pull request.

Take a look at vroni.com

Subscribe to my newsletter

Get new posts when I publish them.

I respect your privacy. Unsubscribe at any time.

Leave a Reply

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