How to Send Emails in Laravel (2026 Guide)

Laravel has one of the best email systems in any framework. Mailables, Blade templates, notifications, queued sending, all built in. But it defaults to SMTP, and most tutorials stop at configuring Mailtrap.
This guide covers how to send production emails from Laravel using API-based providers. You'll get Mailable classes, Blade templates, queued jobs, and the common SaaS email patterns you'll actually need.
Laravel Mail vs Direct API Calls
Laravel's Mail system is excellent. It abstracts away the transport layer, so you can swap between SMTP, SES, Mailgun, or a custom driver without changing your application code.
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 directly
Use the mail driver approach. It gives you Mailables, Blade templates, queueing, and all the Laravel niceties.
Pick a Provider
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management from one API. Has native Stripe integration.
- Resend is developer-friendly with a clean API. Good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences. Has an official Laravel package.
- SendGrid is the enterprise option. Feature-rich, sometimes complex. Good for high volume.
Install
composer require guzzlehttp/guzzlecomposer require resend/resend-laravelcomposer require sendgrid/sendgridAdd your API key to .env:
MAIL_MAILER=sequenzy
SEQUENZY_API_KEY=sq_your_api_key_hereMAIL_MAILER=resend
RESEND_API_KEY=re_your_api_key_hereMAIL_MAILER=sendgrid
SENDGRID_API_KEY=SG.your_api_key_hereConfigure the Mail Driver
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Mail\MailManager;
use App\Mail\Transport\SequenzyTransport;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
app()->make(MailManager::class)->extend('sequenzy', function () {
return new SequenzyTransport(config('services.sequenzy.key'));
});
}
}<?php
// Resend's Laravel package auto-registers the driver.
// Just add to config/services.php:
return [
// ... other services
'resend' => [
'key' => env('RESEND_API_KEY'),
],
];
// Then set MAIL_MAILER=resend in .env
// That's it. The package handles the rest.<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Mail\MailManager;
use App\Mail\Transport\SendGridTransport;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
app()->make(MailManager::class)->extend('sendgrid', function () {
return new SendGridTransport(config('services.sendgrid.key'));
});
}
}For Sequenzy and SendGrid, create the custom transport:
<?php
namespace App\Mail\Transport;
use Illuminate\Http\Client\Factory as HttpClient;
use Symfony\Component\Mailer\SentMessage;
use Symfony\Component\Mailer\Transport\AbstractTransport;
use Symfony\Component\Mime\MessageConverter;
class SequenzyTransport extends AbstractTransport
{
public function __construct(
private string $apiKey,
) {
parent::__construct();
}
protected function doSend(SentMessage $message): void
{
$email = MessageConverter::toEmail($message->getOriginalMessage());
$http = new HttpClient();
$response = $http->withToken($this->apiKey)
->post('https://api.sequenzy.com/v1/transactional/send', [
'to' => collect($email->getTo())->first()?->getAddress(),
'subject' => $email->getSubject(),
'body' => $email->getHtmlBody() ?? $email->getTextBody(),
]);
$response->throw();
}
public function __toString(): string
{
return 'sequenzy';
}
}<?php
// Not needed! The resend/resend-laravel package
// provides the transport automatically.
//
// Just install the package, add RESEND_API_KEY to .env,
// set MAIL_MAILER=resend, and you're 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 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());
$mail->setSubject($email->getSubject());
$mail->addTo(collect($email->getTo())->first()?->getAddress());
$mail->addContent('text/html', $email->getHtmlBody() ?? $email->getTextBody());
$sg->send($mail);
}
public function __toString(): string
{
return 'sendgrid';
}
}Add to config/services.php:
'sequenzy' => [
'key' => env('SEQUENZY_API_KEY'),
],'resend' => [
'key' => env('RESEND_API_KEY'),
],'sendgrid' => [
'key' => env('SENDGRID_API_KEY'),
],Create a Mailable
Now use Laravel's standard Mailable class. The transport handles delivery.
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 function __construct(
public User $user,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "Welcome, {$this->user->name}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.welcome',
);
}
}Create the Blade template:
<!-- resources/views/emails/welcome.blade.php -->
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; background: #f6f9fc; padding: 40px 0;">
<div style="max-width: 480px; margin: 0 auto; background: #fff; padding: 40px; border-radius: 8px;">
<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="{{ config('app.url') }}/dashboard"
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>
</div>
</body>
</html>Send it:
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
// Queued (recommended - non-blocking)
Mail::to($user->email)->queue(new WelcomeMail($user));
// Or synchronous
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\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',
]);
$user = User::create($validated);
// Queue the welcome email
Mail::to($user->email)->queue(new WelcomeMail($user));
return response()->json(['user' => $user], 201);
}
}Laravel Notifications
For more flexibility, use Laravel Notifications. They support multiple channels (email, SMS, Slack) from one class.
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());Common SaaS Email Patterns
Password Reset
Laravel has built-in password reset, but here's a custom version:
<?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)
{
$this->resetUrl = config('app.url') . "/reset-password?token={$token}";
}
public function envelope(): Envelope
{
return new Envelope(subject: 'Reset your password');
}
public function content(): Content
{
return new Content(view: 'emails.password-reset');
}
}Stripe Webhook
<?php
namespace App\Http\Controllers;
use App\Mail\PaymentConfirmedMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
$email = $session->customer_email;
// Send receipt
Mail::to($email)->queue(new PaymentConfirmedMail());
// Add as Sequenzy subscriber for marketing
Http::withToken(config('services.sequenzy.key'))
->post('https://api.sequenzy.com/v1/subscribers', [
'email' => $email,
'tags' => ['customer', 'stripe'],
]);
}
return response()->json(['received' => true]);
}
}<?php
namespace App\Http\Controllers;
use App\Mail\PaymentConfirmedMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
Mail::to($session->customer_email)
->queue(new PaymentConfirmedMail());
}
return response()->json(['received' => true]);
}
}<?php
namespace App\Http\Controllers;
use App\Mail\PaymentConfirmedMail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Stripe\Webhook;
class StripeWebhookController extends Controller
{
public function handle(Request $request)
{
$event = Webhook::constructEvent(
$request->getContent(),
$request->header('Stripe-Signature'),
config('services.stripe.webhook_secret')
);
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
Mail::to($session->customer_email)
->queue(new PaymentConfirmedMail());
}
return response()->json(['received' => true]);
}
}Going to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Required for deliverability.
2. Use Queued Sending
Always use Mail::to()->queue() or implements ShouldQueue on your Mailables. This keeps your HTTP responses fast.
// config/queue.php - use Redis or database driver in production
'default' => env('QUEUE_CONNECTION', 'redis'),Run the worker:
php artisan queue:work --queue=emails,default3. Failed Job Handling
Laravel automatically retries failed jobs. Configure the retry behavior:
// In your Mailable
class WelcomeMail extends Mailable implements ShouldQueue
{
public $tries = 3;
public $backoff = [10, 60, 300]; // seconds between retries
}4. Email Previews
Use php artisan make:mail with --markdown for Markdown mailables, then preview them:
// routes/web.php (local only!)
if (app()->environment('local')) {
Route::get('/mail-preview/welcome', function () {
return new App\Mail\WelcomeMail(App\Models\User::first());
});
}Beyond Transactional: Marketing and Automation
Once your Laravel app sends transactional emails, you'll need onboarding sequences, campaigns, and lifecycle automation. Most teams add Mailchimp or ConvertKit alongside their transactional provider. Two dashboards, subscriber sync issues.
Sequenzy handles both from one API. Transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration.
use Illuminate\Support\Facades\Http;
$api = Http::withToken(config('services.sequenzy.key'))
->baseUrl('https://api.sequenzy.com/v1');
// Add subscriber when they sign up
$api->post('/subscribers', [
'email' => $user->email,
'firstName' => $user->name,
'tags' => ['signed-up'],
'customAttributes' => ['plan' => 'free'],
]);
// Track events to trigger sequences
$api->post('/subscribers/events', [
'email' => $user->email,
'event' => 'onboarding.completed',
'properties' => ['completedSteps' => 5],
]);Set up sequences in the Sequenzy dashboard, and your Laravel app triggers them automatically.
Wrapping Up
Here's what we covered:
- Custom mail drivers to plug any provider into Laravel Mail
- Mailables and Blade templates for maintainable email HTML
- Notifications for multi-channel messaging
- Queued sending for non-blocking email delivery
- Common SaaS patterns: password reset, Stripe webhooks
- Production checklist: domain verification, queue workers, retry handling
The code in this guide is production-ready. Pick your provider, set up the driver, and start sending.