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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
Edit the .env file in your project root:
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:
# 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:
'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'),
],.env has no effect until you clear it. This is the single most common reason a correct mail configuration appears not to work. php artisan config:clear
php artisan config:cache # only if you cache config in productionGenerate a Mailable with a Markdown template:
php artisan make:mail OrderConfirmed --markdown=emails.orders.confirmed<?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:
use App\Mail\OrderConfirmed;
use Illuminate\Support\Facades\Mail;
Mail::to($order->customer->email)->send(new OrderConfirmed($order));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. 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:
// 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.
Add an attachments() method to the Mailable. Files come either from disk or from data you built in memory:
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'),
];
} 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:
// One message per recipient — nobody sees anybody else's address.
foreach ($users as $user) {
Mail::to($user->email)->queue(new WeeklyReport($user));
}In development, write messages to the log instead of delivering them:
# .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:
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);
});| Error | Cause 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 failed | ssl 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. |
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.
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.
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.
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.
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.
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.