Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 030 / 100

Sending email in Laravel: a hands-on guide for engineers

Mailables and markdown templates.

Expona Admin6 min read

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
php artisan make:mail OrderShipped

This creates app/Mail/OrderShipped.php:

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
php artisan make:mail OrderShipped --markdown=emails.orders.shipped

This creates resources/views/emails/orders/shipped.blade.php. Laravel's markdown component system provides semantic HTML components that render consistently across email clients:

php
@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') }}
@endcomponent

The @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:

php
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:

php
Mail::queue(new OrderShipped($order));

Or, if your Mailable implements ShouldQueue, it queues automatically:

php
class OrderShipped extends Mailable implements ShouldQueue
{
    use Queueable, SerializesModels;
    // ...
}

Then simply:

php
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:

php
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:

php
>>> Mail::mailer('log')->send(new OrderShipped(Order::first()))

Or use the preview method directly in a test:

php
$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:

php
MAIL_DRIVER=ses
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1

SES 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:

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:

php
'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_ADDRESS env 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:mail to scaffold Mailables; they keep email logic separate from controllers and models.
  • Implement ShouldQueue in 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_ADDRESS and sender domain match your provider's verified senders.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles