Docs / Laravel

Send email in Laravel over SMTP

Laravel's mail layer sits on top of Symfony Mailer, so pointing it at an SMTP relay is a matter of six lines in .env. This guide sets up Postwing as that relay and then covers what a real application needs: Mailable classes, Markdown templates, attachments, queued sending and testing without delivering anything.

You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Configure Laravel in .env

Edit the .env file in your project root:

.env
MAIL_MAILER=smtp
MAIL_HOST=smtp.postwing.app
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=token-login@your-domain.com
MAIL_PASSWORD=your-token-password
MAIL_FROM_ADDRESS=noreply@your-domain.com
MAIL_FROM_NAME="${APP_NAME}"

MAIL_ENCRYPTION=tls selects STARTTLS, not implicit TLS — that is a long-standing quirk of the name. For implicit TLS use MAIL_PORT=465 with MAIL_ENCRYPTION=ssl. Laravel 11 replaced this with an explicit scheme:

properties
# Laravel 11 and newer also accept the scheme form
MAIL_SCHEME=smtp        # "smtps" for implicit TLS on port 465
MAIL_HOST=smtp.postwing.app
MAIL_PORT=587

Nothing else has to change — config/mail.php already reads these variables. If you edit it directly, the smtp mailer looks like this:

config/mail.php
'smtp' => [
    'transport' => 'smtp',
    'host' => env('MAIL_HOST', 'smtp.postwing.app'),
    'port' => env('MAIL_PORT', 587),
    'encryption' => env('MAIL_ENCRYPTION', 'tls'),
    'username' => env('MAIL_USERNAME'),
    'password' => env('MAIL_PASSWORD'),
    'timeout' => 10,
    'local_domain' => env('MAIL_EHLO_DOMAIN'),
],
Laravel caches configuration, so an edited .env has no effect until you clear it. This is the single most common reason a correct mail configuration appears not to work.
bash
php artisan config:clear
php artisan config:cache   # only if you cache config in production

Send your first email

Generate a Mailable with a Markdown template:

bash
php artisan make:mail OrderConfirmed --markdown=emails.orders.confirmed
app/Mail/OrderConfirmed.php
<?php

namespace App\Mail;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class OrderConfirmed extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(public Order $order) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: "Your order #{$this->order->id} is confirmed",
        );
    }

    public function content(): Content
    {
        return new Content(markdown: 'emails.orders.confirmed');
    }
}

Then send it with the Mail facade:

php
use App\Mail\OrderConfirmed;
use Illuminate\Support\Facades\Mail;

Mail::to($order->customer->email)->send(new OrderConfirmed($order));
The From: address has to be on a domain you have verified, so DKIM signing and SPF alignment apply. Sending as @gmail.com through a third-party relay fails DMARC and lands in spam.

Queue the send instead of blocking the request

SMTP delivery is slow relative to an HTTP response — connection, TLS handshake, authentication, then the message itself. Because the Mailable above uses the Queueable trait, moving it off the request path is a one-word change:

php
// Hands the send to a queue worker — the HTTP response returns immediately.
Mail::to($order->customer->email)->queue(new OrderConfirmed($order));

// Or delay it
Mail::to($order->customer->email)->later(now()->addMinutes(10), new OrderConfirmed($order));

A queued send is also retried automatically if the SMTP server is briefly unreachable, where a synchronous send would surface as a 500.

Send an email with an attachment

Add an attachments() method to the Mailable. Files come either from disk or from data you built in memory:

php
use Illuminate\Mail\Mailables\Attachment;

public function attachments(): array
{
    return [
        Attachment::fromPath(storage_path('invoices/2026-03.pdf'))
            ->as('invoice.pdf')
            ->withMime('application/pdf'),

        // or from data you generated in memory
        Attachment::fromData(fn () => $this->pdf, 'invoice.pdf')
            ->withMime('application/pdf'),
    ];
}

Send to multiple recipients

Passing an array to Mail::to() puts every address in the same To: header, where each recipient sees the others. For user-facing mail, loop and queue one message each:

php
// One message per recipient — nobody sees anybody else's address.
foreach ($users as $user) {
    Mail::to($user->email)->queue(new WeeklyReport($user));
}
Attention!
When using BCC, CC - a separate email will be created for each recipient in the system and your limits will be used.

Test without sending real email

In development, write messages to the log instead of delivering them:

properties
# .env.local — write emails to storage/logs/laravel.log instead of sending
MAIL_MAILER=log

In tests, Mail::fake() intercepts everything and gives you assertions:

php
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmed;

Mail::fake();

$this->post('/orders', $payload);

Mail::assertQueued(OrderConfirmed::class, function ($mail) use ($order) {
    return $mail->order->is($order);
});

Troubleshooting

ErrorCause and fix
Settings ignored after editing .env Config cache. Run php artisan config:clear and php artisan queue:restart.
Expected response code 250 but got code 535 Wrong MAIL_USERNAME / MAIL_PASSWORD. Quote the value in .env if it contains # or a space.
Connection could not be established (timeout) Outbound port blocked by your host. Use 8587 (STARTTLS) or 8465 (implicit TLS).
stream_socket_enable_crypto(): SSL operation failedssl encryption on port 587, or tls on 465. Match the encryption to the port.
Expected response code 250 but got code 550 The MAIL_FROM_ADDRESS domain is not verified, or the token does not cover it.
Queued mail never leaves No worker is running. Start php artisan queue:work and check failed_jobs.

Frequently asked questions

Why is Laravel still using my old mail settings after I changed .env?

Laravel caches configuration. Run php artisan config:clear, and php artisan config:cache again if you cache config in production. Queue workers also hold the old config in memory — restart them with php artisan queue:restart.

Should MAIL_ENCRYPTION be tls or ssl in Laravel?

Use tls with MAIL_PORT=587 — despite the name, that value selects STARTTLS. Use ssl with MAIL_PORT=465 for implicit TLS. On Laravel 11 and newer you can instead set MAIL_SCHEME to smtp or smtps and leave MAIL_ENCRYPTION out entirely.

Why does Laravel throw 'Expected response code 250 but got 535'?

535 is an authentication failure: MAIL_USERNAME or MAIL_PASSWORD is wrong. Use the full login of an SMTP token for your domain and the password shown once when that token was created. Quote the password in .env if it contains a # or a space.

Why do Laravel emails work locally but time out on my server?

Most shared hosts and cloud providers block outbound ports 25, 465 and 587. Set MAIL_PORT to 8587 for STARTTLS or 8465 for implicit TLS and the connection will go through.

How do I send Laravel mail without slowing down the request?

Use Mail::queue() instead of Mail::send() and run a queue worker. The Mailable is serialised to the queue and delivered by the worker, so the user's request does not wait for the SMTP handshake, and a failed send is retried instead of returning a 500.

How do I preview a Mailable without sending it?

Return the Mailable directly from a route — Route::get('/preview', fn () => new OrderConfirmed($order)) — and Laravel renders it in the browser. To capture real sends during development set MAIL_MAILER=log, which writes the full message to storage/logs/laravel.log.

Next steps