Speeding Up Laravel Tests with tmpfs (MySQL in RAM)
If your Laravel test suite is painfully slow and does a lot of database work (multi-tenancy, migrations, seeding), the bottleneck is almost…
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.
There are two main aspects you might want to customize in Laravel's notification emails:
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.
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.');
}
}
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
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));
}
}
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'));
}
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.
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.
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;
}
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>
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
Images not showing: Use absolute URLs in email templates:
<img src="{{ url('images/logo.png') }}" alt="Logo">
Styles not updating: Clear your view cache:
php artisan view:clear
Localization not working: Make sure you're setting the locale before sending:
App::setLocale($user->locale);
$user->notify(new CustomVerifyEmail);
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