Laravel email configuration: two lines that determine inbox or spam
Laravel's mail config is two environment variables. But those two lines determine whether your transactional email lands in the inbox or spam folder. This tutorial shows how to configure Laravel to send through MailKite's SMTP relay: .env setup, Mailable classes, queue configuration, and inbound webhook handling.
Laravel’s mail config is deceptively simple. Set MAIL_MAILER=smtp, add a host and port, and Mail::to() works. But the host and port you choose determine whether your email lands in the inbox or the spam folder. Laravel doesn’t care — it sends to whatever you point it at.
The default for new Laravel projects is MAIL_MAILER=log — emails go to your log files, not to anyone’s inbox. When you switch to a real mailer, the temptation is to use whatever SMTP server your hosting provider gives you. That server shares an IP with every other site on the host. No DKIM. No SPF alignment. Spam folder.
MailKite gives you two things: a proper SMTP relay (smtp.mailkite.dev) with DKIM signing and SPF alignment, and a webhook endpoint for inbound email. Set two environment variables and every Mail::to() call goes out signed, logged, and deliverable.
The default: what goes wrong
A fresh Laravel project has this in .env:
MAIL_MAILER=log
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
When you deploy to production, you change MAIL_MAILER to smtp and point at your hosting provider’s SMTP server. That server:
- Shares an IP with hundreds of other sites. If one sends spam, your deliverability drops.
- Has no DKIM key for your domain. Gmail checks DKIM — no signature means a failing score.
- Sends from its own IP, which isn’t in your SPF record. SPF hard fail.
The result: your password resets, order confirmations, and form submissions land in spam. You don’t find out until a customer complains.
The fix: two environment variables
1. Verify your domain in MailKite
Add your domain to the MailKite dashboard. Publish the four DNS records (MX, SPF, DKIM, DMARC). Under 5 minutes.
2. Set your .env
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailkite.dev
MAIL_PORT=587
MAIL_USERNAME=mailkite
MAIL_PASSWORD=mk_live_your_key_here
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="hello@yourdomain.com"
MAIL_FROM_NAME="${APP_NAME}"
That’s it. Mail::to() now routes through MailKite. No code changes. No plugins. No wrappers.
3. Verify DKIM
Send a test email:
php artisan tinker --execute="Mail::raw('Test from MailKite', fn($m) => $m->to('you@yourdomain.com')->subject('Laravel test')->send());"
Check the headers. DKIM, SPF, and DMARC should all pass.
For teams: config/mail.php
If you want MailKite as a named mailer alongside others (e.g., log for development, mailkite for production), add it to config/mail.php:
// config/mail.php
'mailers' => [
'log' => [
'transport' => 'log',
],
'mailkite' => [
'transport' => 'smtp',
'host' => env('MAIL_HOST', 'smtp.mailkite.dev'),
'port' => env('MAIL_PORT', 587),
'encryption' => env('MAIL_ENCRYPTION', 'tls'),
'username' => env('MAIL_USERNAME', 'mailkite'),
'password' => env('MAIL_PASSWORD'),
'timeout' => 30,
],
],
Set MAIL_MAILER=mailkite in production and MAIL_MAILER=log in development.
Sending with Mailables
Laravel’s Mailable classes work exactly the same — the SMTP relay is transparent:
// app/Mail/OrderConfirmation.php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
class OrderConfirmation extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public $order) {}
public function build()
{
return $this
->subject("Order #{$this->order->id} confirmed")
->view('emails.order-confirmation')
->text('emails.order-confirmation-plain')
->replyTo('support@yourdomain.com');
}
}
Send it:
// Queued (recommended for non-blocking delivery)
Mail::to($order->email)->queue(new OrderConfirmation($order));
// Or immediate
Mail::to($order->email)->send(new OrderConfirmation($order));
The Mailable doesn’t know or care about the SMTP server. Laravel’s mailer abstraction handles the transport. Switch from MailKite to Postmark or SES later and the Mailable stays unchanged.
Queue configuration
Use Mail::queue() instead of Mail::send() for non-blocking delivery. MailKite returns 202 Accepted instantly — the queue worker handles retries if the SMTP connection fails.
Set up a queue worker in production:
php artisan queue:work --sleep=3 --tries=3
Or use Supervisor to keep the worker running:
[program:laravel-worker]
command=php artisan queue:work
autostart=true
autorestart=true
Queued email won’t send until the worker picks it up. If you’re seeing delayed delivery, check that your queue worker is running.
Receiving inbound email
MailKite can POST inbound email to a Laravel route. Add a webhook endpoint:
// routes/web.php
Route::post('/webhooks/mailkite', function () {
$payload = request()->all();
// Verify signature
$sig = request()->header('X-MailKite-Signature');
$secret = config('services.mailkite.webhook_secret');
$computed = hash_hmac('sha256', json_encode($payload), $secret);
if (!hash_equals($computed, $sig)) {
return response()->json(['error' => 'invalid signature'], 401);
}
$from = $payload['from'];
$subject = $payload['subject'];
$text = $payload['text'];
// Process: create a ticket, log, or trigger a job
dispatch(new ProcessInboundEmail($from, $subject, $text));
return response()->json(['ok' => true]);
});
The controller verifies the HMAC signature and dispatches a job. The job processes the email asynchronously — the webhook responds in under 100ms.
Add the webhook secret to your config:
// config/services.php
'mailkite' => [
'webhook_secret' => env('MK_WEBHOOK_SECRET'),
],
How it compares
Laravel mail() default | MailKite SMTP relay | |
|---|---|---|
| SMTP server | Hosting provider’s shared MTA | smtp.mailkite.dev |
| DKIM signing | None | Automatic, per-domain |
| SPF alignment | Fails (shared IP) | Passes (your domain) |
| Inbox placement | ~40–60% | ~95%+ |
| Delivery logs | Server mail.log | Dashboard + API |
| Inbound email | Not supported | Webhook → Laravel route |
| Queue support | Same (Laravel handles it) | Same (MailKite returns 202) |
FAQ
Does this work with Laravel Vapor / Forge / Forge-provisioned servers? Yes. Set the environment variables in your Vapor config or Forge environment panel. MailKite’s SMTP server is standard — no special configuration needed.
What about Laravel’s Mail channel for notifications?
Same story. Laravel’s notification system uses Mail::to() under the hood. The SMTP relay catches everything.
Can I use MailKite alongside Amazon SES or Postmark?
Yes. Configure multiple mailers in config/mail.php and set MAIL_MAILER per environment. Use MailKite for transactional email and SES for marketing, or vice versa.
Two environment variables and your Laravel email stops going to spam. Start free — unlimited domains, no credit card.
Related: Fix WordPress email deliverability — the same problem on a different stack · Parse inbound email to JSON in Node.js — the Node equivalent · Set up email once, reuse every project — the architecture