Sending email in Laravel: a hands-on guide for engineers
Email delivery is a critical path in almost every application we build—order confirmations, password resets, weekly digests, payment receipts. Getting it right means balancing reliability, performance, and maintainability. In this guide, we'll walk through Laravel's mail system from first principles, showing you how to build, queue, test, and deploy email at scale.
Why Laravel's mail system matters
Out of the box, Laravel abstracts away the complexity of SMTP configuration, driver selection, and template rendering. Rather than coupling your business logic directly to email details, you define a Mailable—a class that encapsulates both the message content and its delivery rules. This separation keeps your code clean and testable.
We've found this approach invaluable when migrating between mail providers or when different staging environments need different delivery behavior. A well-structured Mailable survives those transitions without your controller code changing at all.
Creating your first Mailable with php artisan make:mail
Start with the artisan generator:
php artisan make:mail OrderShippedThis creates app/Mail/OrderShipped.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 OrderShipped extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public Order $order,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your order has shipped',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.orders.shipped',
);
}
public function attachments(): array
{
return [];
}
}The structure is straightforward. The envelope() method defines metadata—subject, sender address, reply-to. The content() method specifies which view to render. The attachments() method is where you'd add PDFs, invoices, or other files.
Notice that the constructor receives the Order model. Laravel's SerializesModels trait ensures that when this Mailable is queued, the model is serialized by ID rather than entire data, then rehydrated at send time. This keeps queue payloads small and avoids stale data.
Next, generate the markdown template:
php artisan make:mail OrderShipped --markdown=emails.orders.shippedThis creates resources/views/emails/orders/shipped.blade.php. Laravel's markdown component system provides semantic HTML components that render consistently across email clients:
@component('mail::message')
# Your Order Has Shipped
Your order **#{{ $order->id }}** is on the way.
Tracking number: {{ $order->tracking_number }}
Order total: ${{ number_format($order->total / 100, 2) }}
@component('mail::button', ['url' => route('orders.show', $order)])
View Order
@endcomponent
Thanks,<br />
{{ config('app.name') }}
@endcomponentThe @component('mail::message') wrapper handles the boilerplate. The mail::button component generates a styled CTA button. All components render to clean, mobile-responsive HTML automatically. This saves you from writing raw HTML email templates, which is both tedious and error-prone.
Sending mail synchronously vs. queueing
For testing or development, you can send mail synchronously:
Mail::send(new OrderShipped($order));But in production, synchronous mail is a liability. An SMTP timeout blocks your request. A slow mail provider can make your API slow. That's why we queue:
Mail::queue(new OrderShipped($order));Or, if your Mailable implements ShouldQueue, it queues automatically:
class OrderShipped extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
// ...
}Then simply:
Mail::send(new OrderShipped($order));
The framework detects that the Mailable should queue and dispatches it to your configured queue driver (Redis, database, SQS). Your request returns immediately. A worker process (running via php artisan queue:work) pulls the job and sends the email asynchronously.
For critical workflows like password resets, you might still send synchronously to reduce friction. For marketing emails or bulk notifications, queuing is essential. We've seen unqueued mail bring down production systems when a mail provider had a hiccup.
Previewing mailables in the browser
Before sending real emails, preview the rendered output locally. Laravel provides a route for this. Add it to a debug or testing route file:
Route::get('/mails/preview/{mailable}', function ($mailable) {
$mailables = [
'order-shipped' => fn() => new OrderShipped(Order::first()),
'password-reset' => fn() => new PasswordReset(User::first()),
];
if (!isset($mailables[$mailable])) {
abort(404);
}
return new Illuminate\Mail\Mailable(...$mailables[$mailable]());
})->middleware('auth', 'admin');
Better yet, use Laravel's built-in php artisan tinker:
>>> Mail::mailer('log')->send(new OrderShipped(Order::first()))
Or use the preview method directly in a test:
$mail = new OrderShipped($order);
return $mail->render();We typically keep a simple debug route protected by auth and admin middleware, then visit it on localhost during development. You can inspect the raw HTML, check that links resolve correctly, and verify that dynamic data renders as expected.
Using SES, Postmark, and Mailgun in production
By default, Laravel sends via SMTP (configured in .env). For production, most teams switch to a transactional email service. These providers offer higher deliverability, bounce handling, webhook events, and detailed analytics—critical for any application at scale.
Amazon SES: Set up an IAM user with SES permissions, store the keys in .env, and configure the driver:
MAIL_DRIVER=ses
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1SES is cost-effective for high volume but requires careful reputation management. If your bounce rate gets too high, AWS may throttle or disable your account.
Postmark: Simpler onboarding and excellent deliverability. Add the driver to config/mail.php:
'postmark' => [
'transport' => 'postmark',
'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID', 'outbound'),
],Then set MAIL_DRIVER=postmark and POSTMARK_TOKEN in .env. Postmark's dashboard is intuitive for tracking bounces and complaints, which is valuable when debugging deliverability issues.
Mailgun: Powerful API, detailed event webhooks, and competitive pricing. Configure similarly:
'mailgun' => [
'transport' => 'mailgun',
'domain' => env('MAILGUN_DOMAIN'),
'secret' => env('MAILGUN_SECRET'),
],Each provider offers webhooks for bounce, complaint, and delivery events. Wire these up to log failed sends or automatically disable bounced email addresses in your database. This is crucial for maintaining sender reputation.
Common mistakes
- Sending un-queued mail in production: Even if you think a send is fast, one slow provider call can cascade into request timeouts. Always queue in production.
- Hardcoding email addresses in templates: Use configuration or pass data through the Mailable. Hardcoded strings become technical debt when you need to change a support email or adjust who receives notifications.
- Not testing mail during development: Preview mailables before deployment. Typos, broken links, and formatting issues reveal themselves immediately.
- Ignoring queue monitoring: A queued mail can fail silently if your queue worker crashes. Set up monitoring on queue health and worker processes.
- Not handling provider-specific quirks: HTML email rendering varies wildly across clients. Test in Litmus or Email on Acid if you're sending complex layouts. Stick to simple, semantic markup where possible.
- Forgetting to set a from address: If your
MAIL_FROM_ADDRESSenv var is missing, sends fail. Always verify this is set and matches your mail provider's verified sender domain.
Key takeaways
- Use
php artisan make:mailto scaffold Mailables; they keep email logic separate from controllers and models. - Implement
ShouldQueuein Mailables so they queue automatically, keeping your request-response cycle fast. - Preview mailables in the browser during development using
Mail::mailer('log')or a debug route protected by auth. - Choose a transactional email provider (SES, Postmark, Mailgun) for production; SMTP is fine for dev/staging but not reliable at scale.
- Wire up provider webhooks to track bounces and complaints so you maintain sender reputation over time.
- Always verify
MAIL_FROM_ADDRESSand sender domain match your provider's verified senders.






Comments
Loading comments…