How to Send Emails in Laravel (2026 Guide)

Most "how to send email in Laravel" tutorials stop at configuring Mailtrap and sending a basic Mailable. That's fine for testing. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences to real users.
This guide covers the full picture: building custom mail drivers, creating Mailable classes with Blade templates, using notifications for multi-channel messaging, queued sending, Stripe webhook handling, and production-ready error handling. For a broader PHP overview, see our PHP email guide. All code examples use Laravel 11+ conventions.
Pick an Email Provider
You have three solid options. Each code example below lets you switch between them.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. It also has built-in retries and native Stripe integration.
- Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences. Has an official Laravel package that auto-registers the driver.
- SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.
Laravel Mail vs Direct API
Laravel's Mail system is excellent. It abstracts the transport layer, so you can swap between SMTP, SES, Mailgun, or a custom driver without changing application code. You get Mailables, Blade templates, queuing, and notifications for free.
You have two options:
- Custom mail driver — plug your provider into Laravel's Mail system (recommended)
- Direct HTTP calls — bypass Laravel Mail and call the API with
Http::facade
Use the mail driver approach. You keep all of Laravel's email features and can switch providers by changing one environment variable.
Install Dependencies
# Guzzle is included with Laravel by default
# No additional packages neededcomposer require resend/resend-laravelcomposer require sendgrid/sendgridAdd your API key to .env:
MAIL_MAILER=sequenzy
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="Your App"
SEQUENZY_API_KEY=sq_your_api_key_hereMAIL_MAILER=resend
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="Your App"
RESEND_API_KEY=re_your_api_key_hereMAIL_MAILER=sendgrid
MAIL_FROM_ADDRESS=noreply@yourdomain.com
MAIL_FROM_NAME="Your App"
SENDGRID_API_KEY=SG.your_api_key_hereAdd to config/services.php:
'sequenzy' => [
'key' => env('SEQUENZY_API_KEY'),
],'resend' => [
'key' => env('RESEND_API_KEY'),
],'sendgrid' => [
'key' => env('SENDGRID_API_KEY'),
],Configure the Mail Driver
<?php
namespace App\Providers;
use App\Mail\Transport\SequenzyTransport;
use Illuminate\Mail\MailManager;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
app()->make(MailManager::class)->extend('sequenzy', function () {
return new SequenzyTransport(
apiKey: config('services.sequenzy.key'),
);
});
}
}<?php
// Resend's Laravel package auto-registers the mail driver.
// Just set MAIL_MAILER=resend in .env and add the API key
// to config/services.php. That's it.
// The package provides:
// - Mail driver (auto-registered)
// - Resend facade for direct API access
// - Support for tags and metadata on emails<?php
namespace App\Providers;
use App\Mail\Transport\SendGridTransport;
use Illuminate\Mail\MailManager;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
app()->make(MailManager::class)->extend('sendgrid', function () {
return new SendGridTransport(
apiKey: config('services.sendgrid.key'),
);
});
}
}For Sequenzy and SendGrid, create the custom transport class:
<?php
namespace App\Mail\Transport;
use Illuminate\Support\Facades\Http;
use Symfony\Component\Mailer\Envelope;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\MessageConverter;
class SequenzyTransport extends AbstractTransport
{
private const API_URL = 'https://api.sequenzy.com/v1/transactional/send';
public function __construct(
private readonly string $apiKey,
) {
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$email = MessageConverter::toEmail($message->getOriginalMessage());
$response = Http::withToken($this->apiKey)
->timeout(30)
->post(self::API_URL, [
'to' => collect($email->getTo())
->map(fn ($addr) => $addr->getAddress())
->first(),
'subject' => $email->getSubject(),
'body' => $email->getHtmlBody() ?? $email->getTextBody(),
]);
if ($response->failed()) {
throw new \RuntimeException(
"Sequenzy API error ({$response->status()}): {$response->body()}"
);
}
}
public function __toString(): string
{
return 'sequenzy';
}
}<?php
// Not needed! The resend/resend-laravel package
// provides the transport automatically.
//
// Just install: composer require resend/resend-laravel
// Add RESEND_API_KEY to .env
// Set MAIL_MAILER=resend
// Done.<?php
namespace App\Mail\Transport;
use SendGrid;
use SendGrid\Mail\Mail;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\MessageConverter;
class SendGridTransport extends AbstractTransport
{
public function __construct(
private readonly string $apiKey,
) {
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$email = MessageConverter::toEmail($message->getOriginalMessage());
$sg = new SendGrid($this->apiKey);
$mail = new Mail();
$mail->setFrom(
collect($email->getFrom())->first()?->getAddress(),
collect($email->getFrom())->first()?->getName()
);
$mail->setSubject($email->getSubject());
$mail->addTo(collect($email->getTo())->first()?->getAddress());
$mail->addContent('text/html', $email->getHtmlBody() ?? $email->getTextBody());
$response = $sg->send($mail);
if ($response->statusCode() >= 400) {
throw new \RuntimeException(
"SendGrid error ({$response->statusCode()}): {$response->body()}"
);
}
}
public function __toString(): string
{
return 'sendgrid';
}
}Create a Mailable
Now use Laravel's standard Mailable class. The transport handles the actual delivery — your application code stays provider-agnostic.
php artisan make:mail WelcomeMail<?php
// app/Mail/WelcomeMail.php
namespace App\Mail;
use App\Models\User;
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 WelcomeMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(
public readonly User $user,
) {
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: "Welcome, {$this->user->name}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.welcome',
with: [
'dashboardUrl' => config('app.url') . '/dashboard',
],
);
}
}Create the Blade template:
<!-- resources/views/emails/welcome.blade.php -->
@extends('emails.layout')
@section('content')
<h1 style="font-size:24px;margin-bottom:16px;">
Welcome, {{ $user->name }}
</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your account is ready. Click below to get started.
</p>
<a href="{{ $dashboardUrl }}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-weight:600;margin-top:16px;">
Go to Dashboard
</a>
@endsectionCreate a shared layout for all emails:
<!-- resources/views/emails/layout.blade.php -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
<div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
@yield('content')
</div>
<div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px;">
<p>© {{ date('Y') }} {{ config('app.name') }}. All rights reserved.</p>
<p>
<a href="{{ config('app.url') }}/unsubscribe" style="color:#9ca3af;">Unsubscribe</a>
</p>
</div>
</body>
</html>Send the email:
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
// Queued (recommended — non-blocking)
Mail::to($user->email)->queue(new WelcomeMail($user));
// Or synchronous (blocks the request)
Mail::to($user->email)->send(new WelcomeMail($user));Send from Controllers
<?php
// app/Http/Controllers/RegistrationController.php
namespace App\Http\Controllers;
use App\Mail\WelcomeMail;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
class RegistrationController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:users',
'password' => 'required|min:8|confirmed',
]);
$user = User::create([
...$validated,
'password' => Hash::make($validated['password']),
]);
// Queue the welcome email (non-blocking)
Mail::to($user->email)->queue(new WelcomeMail($user));
return response()->json(['user' => $user], 201);
}
}Laravel Notifications
For multi-channel messaging (email + Slack + database), use Notifications. They're more flexible than Mailables when you need to notify users through multiple channels:
php artisan make:notification WelcomeNotification<?php
// app/Notifications/WelcomeNotification.php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class WelcomeNotification extends Notification implements ShouldQueue
{
use Queueable;
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
return (new MailMessage)
->subject("Welcome, {$notifiable->name}")
->greeting("Welcome, {$notifiable->name}!")
->line('Your account is ready. Click below to get started.')
->action('Go to Dashboard', config('app.url') . '/dashboard')
->line('Let us know if you have any questions.');
}
}
// Send it
$user->notify(new WelcomeNotification());When to Use Mailables vs Notifications
| Use Mailables when... | Use Notifications when... |
|---|---|
| The email is the whole point (receipts, newsletters) | You might add SMS/Slack/push later |
| You need full Blade template control | The built-in notification template is fine |
| You need attachments or complex HTML | You want multi-channel from one class |
Common Email Patterns for SaaS
Password Reset
Laravel has built-in password reset, but here's a custom version when you need full control:
<?php
// app/Mail/PasswordResetMail.php
namespace App\Mail;
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 PasswordResetMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public string $resetUrl;
public function __construct(string $token, string $email)
{
$this->resetUrl = config('app.url') . '/reset-password?' . http_build_query([
'token' => $token,
'email' => $email,
]);
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Reset your password');
}
public function content(): Content
{
return new Content(view: 'emails.password-reset');
}
}<!-- resources/views/emails/password-reset.blade.php -->
@extends('emails.layout')
@section('content')
<h2 style="font-size:20px;">Password Reset</h2>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Click the button below to reset your password. This link expires in 1 hour.
</p>
<a href="{{ $resetUrl }}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
margin-top:16px;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
@endsectionPayment Receipt
<?php
// app/Mail/PaymentReceiptMail.php
namespace App\Mail;
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 PaymentReceiptMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public string $formatted;
public function __construct(
public readonly int $amount,
public readonly string $plan,
public readonly string $invoiceUrl,
) {
$this->formatted = '$' . number_format($amount / 100, 2);
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
subject: "Payment receipt - {$this->formatted}",
);
}
public function content(): Content
{
return new Content(view: 'emails.receipt');
}
}<!-- resources/views/emails/receipt.blade.php -->
@extends('emails.layout')
@section('content')
<h2 style="font-size:20px;">Payment Received</h2>
<p style="font-size:16px;color:#374151;">Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">{{ $plan }}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">{{ $formatted }}</td>
</tr>
</table>
<a href="{{ $invoiceUrl }}" style="color:#f97316;">View full invoice</a>
@endsectionStripe Webhook Handler
For more on automating emails from Stripe events, see our Stripe email automation guide.
<?php
namespace App\Http\Controllers;
use App\Mail\PaymentReceiptMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
// Verify Stripe signature
$elements = collect(explode(',', $signature))
->mapWithKeys(function ($part) {
[$key, $value] = explode('=', $part, 2);
return [$key => $value];
});
$signedPayload = $elements->get('t') . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, $secret);
if (!hash_equals($expected, $elements->get('v1', ''))) {
return response()->json(['error' => 'Invalid signature'], 400);
}
$event = json_decode($payload, true);
match ($event['type'] ?? null) {
'checkout.session.completed' => $this->handleCheckout($event['data']['object']),
'invoice.payment_failed' => $this->handlePaymentFailed($event['data']['object']),
default => null,
};
return response()->json(['received' => true]);
}
private function handleCheckout(array $session): void
{
$email = $session['customer_email'];
// Send receipt
Mail::to($email)->queue(new PaymentReceiptMail(
amount: $session['amount_total'],
plan: 'Pro',
invoiceUrl: $session['invoice'] ?? '#',
));
// Add as Sequenzy subscriber for marketing
Http::withToken(config('services.sequenzy.key'))
->post('https://api.sequenzy.com/v1/subscribers', [
'email' => $email,
'tags' => ['customer', 'stripe'],
]);
Log::info('Checkout completed', ['email' => $email]);
}
private function handlePaymentFailed(array $invoice): void
{
$email = $invoice['customer_email'];
Mail::to($email)->queue(new \App\Mail\PaymentFailedMail());
Log::warning('Payment failed', ['email' => $email]);
}
}<?php
namespace App\Http\Controllers;
use App\Mail\PaymentReceiptMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
$elements = collect(explode(',', $signature))
->mapWithKeys(function ($part) {
[$key, $value] = explode('=', $part, 2);
return [$key => $value];
});
$signedPayload = $elements->get('t') . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, $secret);
if (!hash_equals($expected, $elements->get('v1', ''))) {
return response()->json(['error' => 'Invalid signature'], 400);
}
$event = json_decode($payload, true);
match ($event['type'] ?? null) {
'checkout.session.completed' => $this->handleCheckout($event['data']['object']),
'invoice.payment_failed' => $this->handlePaymentFailed($event['data']['object']),
default => null,
};
return response()->json(['received' => true]);
}
private function handleCheckout(array $session): void
{
Mail::to($session['customer_email'])->queue(new PaymentReceiptMail(
amount: $session['amount_total'],
plan: 'Pro',
invoiceUrl: $session['invoice'] ?? '#',
));
Log::info('Checkout completed', ['email' => $session['customer_email']]);
}
private function handlePaymentFailed(array $invoice): void
{
Mail::to($invoice['customer_email'])->queue(new \App\Mail\PaymentFailedMail());
Log::warning('Payment failed', ['email' => $invoice['customer_email']]);
}
}<?php
namespace App\Http\Controllers;
use App\Mail\PaymentReceiptMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
$elements = collect(explode(',', $signature))
->mapWithKeys(function ($part) {
[$key, $value] = explode('=', $part, 2);
return [$key => $value];
});
$signedPayload = $elements->get('t') . '.' . $payload;
$expected = hash_hmac('sha256', $signedPayload, $secret);
if (!hash_equals($expected, $elements->get('v1', ''))) {
return response()->json(['error' => 'Invalid signature'], 400);
}
$event = json_decode($payload, true);
match ($event['type'] ?? null) {
'checkout.session.completed' => $this->handleCheckout($event['data']['object']),
'invoice.payment_failed' => $this->handlePaymentFailed($event['data']['object']),
default => null,
};
return response()->json(['received' => true]);
}
private function handleCheckout(array $session): void
{
Mail::to($session['customer_email'])->queue(new PaymentReceiptMail(
amount: $session['amount_total'],
plan: 'Pro',
invoiceUrl: $session['invoice'] ?? '#',
));
Log::info('Checkout completed', ['email' => $session['customer_email']]);
}
private function handlePaymentFailed(array $invoice): void
{
Mail::to($invoice['customer_email'])->queue(new \App\Mail\PaymentFailedMail());
Log::warning('Payment failed', ['email' => $invoice['customer_email']]);
}
}Register the route (exclude CSRF for webhooks):
// routes/api.php
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']);Error Handling
Laravel's queue system handles retries automatically. Configure retry behavior on your Mailables:
<?php
// app/Mail/WelcomeMail.php
class WelcomeMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
// Retry up to 3 times
public int $tries = 3;
// Wait 10s, 60s, then 5min between retries
public array $backoff = [10, 60, 300];
// Give up after 5 minutes total
public int $timeout = 300;
// Delete the job if the model is gone
public bool $deleteWhenMissingModels = true;
public function failed(\Throwable $exception): void
{
// Called when all retries exhausted
Log::error('Welcome email permanently failed', [
'user_id' => $this->user->id,
'email' => $this->user->email,
'error' => $exception->getMessage(),
]);
}
}For direct API calls (outside the Mail facade), add retry logic:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class SequenzyService
{
private const BASE_URL = 'https://api.sequenzy.com/v1';
public function __construct(
private readonly string $apiKey,
) {}
public static function fromConfig(): self
{
return new self(config('services.sequenzy.key'));
}
public function sendTransactional(string $to, string $subject, string $body): array
{
$response = Http::withToken($this->apiKey)
->timeout(30)
->retry(3, function (int $attempt) {
return $attempt * 1000; // 1s, 2s, 3s backoff
}, function (\Exception $e, \Illuminate\Http\Client\PendingRequest $request) {
// Only retry on 5xx and network errors
if ($e instanceof \Illuminate\Http\Client\RequestException) {
return $e->response->status() >= 500;
}
return true;
})
->post(self::BASE_URL . '/transactional/send', [
'to' => $to,
'subject' => $subject,
'body' => $body,
]);
if ($response->failed()) {
Log::error('Email send failed', [
'to' => $to,
'status' => $response->status(),
'body' => $response->json(),
]);
$response->throw();
}
return $response->json();
}
}<?php
// With Resend's Laravel package, errors are handled by
// the mail driver. Failed queue jobs are retried automatically
// based on your Mailable's $tries and $backoff settings.
//
// For direct Resend API calls:
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Resend;
class EmailService
{
public function sendDirect(string $to, string $subject, string $html): array
{
try {
$resend = Resend::client(config('services.resend.key'));
$result = $resend->emails->send([
'from' => config('mail.from.address'),
'to' => $to,
'subject' => $subject,
'html' => $html,
]);
return ['id' => $result->id];
} catch (\Exception $e) {
Log::error('Direct email send failed', [
'to' => $to,
'error' => $e->getMessage(),
]);
throw $e;
}
}
}<?php
// With the SendGrid custom transport, errors are handled by
// the mail driver. Failed queue jobs are retried automatically.
//
// For direct SendGrid API calls:
namespace App\Services;
use Illuminate\Support\Facades\Log;
use SendGrid;
use SendGrid\Mail\Mail;
class EmailService
{
public function sendDirect(string $to, string $subject, string $html): array
{
$sg = new SendGrid(config('services.sendgrid.key'));
$email = new Mail();
$email->setFrom(config('mail.from.address'));
$email->setSubject($subject);
$email->addTo($to);
$email->addContent('text/html', $html);
$response = $sg->send($email);
if ($response->statusCode() >= 400) {
Log::error('Direct email send failed', [
'to' => $to,
'status' => $response->statusCode(),
'body' => $response->body(),
]);
throw new \RuntimeException("SendGrid error: {$response->body()}");
}
return ['status' => $response->statusCode()];
}
}Queued Sending
Always queue your emails. It keeps HTTP responses fast and adds automatic retry on failure.
Configure the Queue
// .env
QUEUE_CONNECTION=redis // or database, sqs
// If using database driver:
php artisan queue:table
php artisan migrateRun Queue Workers
# Development
php artisan queue:work --queue=emails,default
# Production (with Supervisor)
# /etc/supervisor/conf.d/laravel-worker.conf
[program:laravel-email-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/artisan queue:work redis --queue=emails --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
numprocs=2Monitor Failed Jobs
# View failed jobs
php artisan queue:failed
# Retry a specific failed job
php artisan queue:retry <job-id>
# Retry all failed jobs
php artisan queue:retry allRate Limiting Email Queue
Use Laravel's rate limiter to prevent blasting too many emails:
<?php
// app/Providers/AppServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Support\Facades\RateLimiter;
public function boot(): void
{
RateLimiter::for('emails', function (object $job) {
return Limit::perMinute(60);
});
}
// In your Mailable
class WelcomeMail extends Mailable implements ShouldQueue
{
public function middleware(): array
{
return [new RateLimited('emails')];
}
}Testing
Laravel makes email testing excellent. No real emails are ever sent in tests.
<?php
// tests/Feature/RegistrationTest.php
namespace Tests\Feature;
use App\Mail\WelcomeMail;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_welcome_email_is_queued_on_registration(): void
{
Mail::fake();
$response = $this->postJson('/api/register', [
'name' => 'Jane Doe',
'email' => 'jane@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertStatus(201);
Mail::assertQueued(WelcomeMail::class, function ($mail) {
return $mail->hasTo('jane@example.com')
&& $mail->user->name === 'Jane Doe';
});
}
public function test_welcome_email_content(): void
{
$user = User::factory()->create(['name' => 'Jane']);
$mailable = new WelcomeMail($user);
$mailable->assertSeeInHtml('Welcome, Jane');
$mailable->assertSeeInHtml('Go to Dashboard');
$mailable->assertHasSubject('Welcome, Jane');
}
public function test_no_email_sent_on_invalid_registration(): void
{
Mail::fake();
$this->postJson('/api/register', [
'name' => '',
'email' => 'not-an-email',
'password' => '123',
])->assertStatus(422);
Mail::assertNothingQueued();
}
}Testing Notifications
<?php
use App\Notifications\WelcomeNotification;
use Illuminate\Support\Facades\Notification;
public function test_welcome_notification_is_sent(): void
{
Notification::fake();
$user = User::factory()->create();
$user->notify(new WelcomeNotification());
Notification::assertSentTo($user, WelcomeNotification::class);
}Preview Emails in Browser
// routes/web.php (local only!)
if (app()->environment('local')) {
Route::get('/mail-preview/{mailable}', function (string $mailable) {
return match ($mailable) {
'welcome' => new \App\Mail\WelcomeMail(\App\Models\User::first()),
'reset' => new \App\Mail\PasswordResetMail('fake-token', 'user@test.com'),
'receipt' => new \App\Mail\PaymentReceiptMail(4999, 'Pro', '#'),
default => abort(404),
};
});
}Visit http://localhost:8000/mail-preview/welcome to see the rendered email.
Going to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Our email authentication guide covers the full process. Without this, emails go straight to spam.
2. Use a Dedicated Sending Domain
Send from mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.
3. Always Queue Emails
Never use Mail::to()->send() in production controllers. Always use ->queue() or implements ShouldQueue on the Mailable. This keeps HTTP responses fast and gives you automatic retries.
4. Use Horizon for Queue Monitoring
composer require laravel/horizon
php artisan horizon:install
php artisan horizonHorizon gives you a dashboard at /horizon with real-time queue metrics, failed job inspection, and retry controls.
Production Checklist
- [ ] Domain verified (SPF, DKIM, DMARC)
- [ ] Dedicated sending domain (mail.yourapp.com)
- [ ] MAIL_MAILER set in production .env
- [ ] All Mailables implement ShouldQueue
- [ ] Queue workers running via Supervisor
- [ ] Failed job monitoring (Horizon or queue:failed)
- [ ] Rate limiting on email queue
- [ ] Retry configuration ($tries, $backoff) on Mailables
- [ ] Email preview route (local only)
- [ ] Mail::fake() in all email tests
- [ ] Logging on webhook handlers
- [ ] CSRF exclusion for webhook routes
- [ ] Stripe webhook signature verification
Beyond Transactional: Marketing Emails and Sequences
At some point, you'll want more than one-off transactional emails. You'll want to:
- Send onboarding sequences that guide new users through your product over several days
- Run marketing campaigns to announce features or share updates
- Automate lifecycle emails based on what users do (or don't do) in your app
- Track engagement to see which emails get opened and clicked
Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.
Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.
Here's what subscriber management looks like from Laravel:
<?php
use Illuminate\Support\Facades\Http;
$api = Http::withToken(config('services.sequenzy.key'))
->baseUrl('https://api.sequenzy.com/v1');
// Add a subscriber when they sign up
$api->post('/subscribers', [
'email' => $user->email,
'firstName' => $user->name,
'tags' => ['signed-up'],
'customAttributes' => ['plan' => 'free', 'source' => 'organic'],
]);
// Tag them when they upgrade
$api->post('/subscribers/tags', [
'email' => $user->email,
'tag' => 'customer',
]);
// Track events to trigger automated sequences
$api->post('/subscribers/events', [
'email' => $user->email,
'event' => 'onboarding.completed',
'properties' => ['completedSteps' => 5],
]);You can wrap this in a Laravel service and call it from model observers:
<?php
// app/Observers/UserObserver.php
namespace App\Observers;
use App\Models\User;
use App\Services\SequenzyService;
class UserObserver
{
public function __construct(
private readonly SequenzyService $sequenzy,
) {}
public function created(User $user): void
{
$this->sequenzy->addSubscriber($user->email, [
'firstName' => $user->name,
'tags' => ['signed-up'],
]);
}
}FAQ
Should I use the Laravel Mail facade or call the API directly?
Use the Mail facade with a custom transport. You get Mailables, Blade templates, queued sending, notifications, and can swap providers by changing one environment variable. Direct API calls are only useful for provider-specific features (like Sequenzy's subscriber management) that aren't email sending.
How do I send emails from an Artisan command?
Same as anywhere else — the Mail facade works in commands:
public function handle(): void
{
$users = User::where('created_at', '<', now()->subDays(3))
->whereNull('onboarding_completed_at')
->get();
foreach ($users as $user) {
Mail::to($user->email)->queue(new OnboardingReminderMail($user));
}
}Schedule it in routes/console.php:
Schedule::command('emails:onboarding-reminder')->dailyAt('09:00');What's the difference between send() and queue()?
send() sends the email immediately (synchronous — blocks the request). queue() dispatches it to your queue (asynchronous — returns instantly). Always use queue() in web requests. The only time to use send() is in background jobs or Artisan commands where blocking doesn't matter.
How do I test that an email contains specific content?
Laravel 11+ Mailables have assertion methods:
$mailable = new WelcomeMail($user);
$mailable->assertSeeInHtml('Welcome, Jane');
$mailable->assertSeeInHtml('Go to Dashboard');
$mailable->assertDontSeeInHtml('Unsubscribe'); // if applicable
$mailable->assertHasSubject('Welcome, Jane');How do I use Markdown templates?
Laravel has a built-in Markdown email renderer:
php artisan make:mail WelcomeMail --markdown=emails.welcomeThis generates a Mailable that uses new Content(markdown: 'emails.welcome') and a Markdown template in resources/views/emails/welcome.blade.php. It auto-wraps your content in a styled layout.
Can I use the same Mailable for multiple providers?
Yes, that's the whole point of the custom transport pattern. Your Mailable doesn't know or care which provider sends it. Change MAIL_MAILER in .env and the same Mailable sends through a different provider. No code changes.
How do I send emails to multiple recipients?
// To multiple addresses
Mail::to(['jane@example.com', 'john@example.com'])
->queue(new AnnouncementMail());
// With CC and BCC
Mail::to($user->email)
->cc('team@yourcompany.com')
->bcc('archive@yourcompany.com')
->queue(new ImportantMail());How do I add attachments?
class InvoiceMail extends Mailable
{
public function attachments(): array
{
return [
Attachment::fromPath('/path/to/invoice.pdf')
->as('invoice.pdf')
->withMime('application/pdf'),
// Or from storage
Attachment::fromStorage('invoices/invoice-123.pdf'),
];
}
}How do I prevent duplicate emails?
Use Laravel's ShouldBeUnique interface on your Mailable:
class WelcomeMail extends Mailable implements ShouldQueue, ShouldBeUnique
{
public function uniqueId(): string
{
return 'welcome-' . $this->user->id;
}
public int $uniqueFor = 3600; // 1 hour
}Wrapping Up
Here's what we covered:
- Custom mail drivers to plug any provider into Laravel's Mail system
- Mailables with Blade templates for type-safe, maintainable emails
- Notifications for multi-channel messaging
- Queued sending with automatic retries and rate limiting
- Stripe webhooks with HMAC signature verification
- Testing with Mail::fake() and content assertions
- Production setup: Horizon, Supervisor, failed job handling
The code in this guide is production-ready. Pick your provider, set up the driver, and start shipping emails.
Frequently Asked Questions
Should I use Laravel's Mail facade or a provider SDK directly?
Use Laravel's Mail facade. It provides a clean abstraction over email providers, supports queuing out of the box, and lets you swap providers by changing a config value. Direct SDK usage bypasses Laravel's mail features and makes your code less portable.
How do I send emails in the background in Laravel?
Implement the ShouldQueue interface on your Mailable class. Laravel automatically pushes queued Mailables to your queue driver (Redis, SQS, database). Run php artisan queue:work to process the queue. This is the recommended approach for all non-trivial email sends.
How do I create email templates in Laravel?
Use php artisan make:mail WelcomeEmail --markdown to create a Mailable with a Markdown template. Laravel's Markdown components (@component('mail::button')) generate email-safe HTML automatically. For custom HTML, use Blade views instead of Markdown.
How do I test email sending in Laravel?
Use Mail::fake() in your test to prevent real sends. Then assert with Mail::assertSent(WelcomeEmail::class) and check recipients, content, and attachments. Laravel's mail faking is built-in and requires no additional setup.
How do I preview email templates in Laravel?
Return the Mailable from a route: Route::get('/preview', fn() => new WelcomeEmail($user)). Laravel renders the email as an HTML page. Use this in development only. You can also use php artisan mail:preview with Laravel's Mailpit integration.
How do I handle failed email sends in Laravel?
Queued emails automatically retry based on your queue configuration. Set $tries and $backoff properties on your Mailable for custom retry behavior. Failed jobs go to the failed_jobs table. Monitor failures with php artisan queue:failed or Laravel Horizon.
Should I use Laravel Notifications or Mailables?
Use Notifications when you need to send via multiple channels (email, SMS, Slack) or want per-user notification preferences. Use Mailables when you're only sending email and want more control over the template and content. Both support queuing.
How do I switch email providers in Laravel?
Change the MAIL_MAILER environment variable and update credentials. Laravel's mail system abstracts the provider, so your Mailable classes don't change. For custom providers, create a mail transport that implements Laravel's Transport interface.
How do I send emails with attachments in Laravel?
Use the attach() method on your Mailable: $this->attach('/path/to/file.pdf'). For inline images, use attachData() with raw content. For attachments from cloud storage, use attachFromStorage() to pull from S3 or other disks.
How do I use Laravel Horizon for email queue monitoring?
Install Horizon with composer require laravel/horizon. It provides a dashboard at /horizon showing queue throughput, failed jobs, and processing times. Configure queue workers and balancing strategies in config/horizon.php. Essential for production email operations.