Let's Connect

Developer workstation with code on screen representing a Laravel SMTP sending platform

A Laravel SMTP platform that serves multiple customers has one hard requirement that breaks every tutorial: each customer domain must send through its own mailer, with its own host, credentials, DKIM key, and sender reputation. The static config/mail.php cannot express that, because the sending identity is not known until you pull a message off the queue. The fix is to stop relying on the file-based config and build the mailer transport at runtime, per send, from the domain's stored settings. I ran exactly this in production, and below is the architecture that kept one customer's bad list from dragging down everyone else's deliverability.

Why does the static config/mail.php fall apart here?

config/mail.php defines a fixed set of mailers at boot. That model assumes the application has one identity. A sending platform inverts it: the From address, SMTP host, and signing key are properties of the customer's domain, loaded from the database, and they change message to message. Defining a thousand mailers in a config file is unworkable, and even if you tried, the MailManager resolves and caches a mailer the first time its name is used, so a long-running queue worker would happily reuse one customer's transport for the next job. The clean answer is to construct the mailer for each domain on demand and never trust the default.

I store everything a send needs against the domain. A sending_domains table holds the SMTP relay host and port, the encrypted credentials, the verified sending subdomain, and the DKIM private key. The selector becomes a service, not a config lookup.

app/Services/DomainMailerFactory.php
<?php

namespace App\Services;

use App\Models\SendingDomain;
use Illuminate\Mail\Mailer;
use Illuminate\Support\Facades\Mail;

class DomainMailerFactory
{
    /**
     * Build a fresh Mailer bound to one customer domain's SMTP relay.
     */
    public function for(SendingDomain $domain): Mailer
    {
        // Mail::build() accepts an array shaped like one config/mail.php entry
        // and returns a self-contained Mailer using THIS transport, without
        // registering it globally or touching config/mail.php.
        return Mail::build([
            'transport' => 'smtp',
            'host'      => $domain->smtp_host,
            'port'      => $domain->smtp_port,
            'username'  => $domain->smtp_username,
            // credentials are stored encrypted; decrypt only at send time
            'password'  => decrypt($domain->smtp_password),
            // 587 uses STARTTLS ('tls'); 465 uses implicit TLS ('ssl')
            'encryption' => $domain->smtp_port === 465 ? 'ssl' : 'tls',
        ]);
    }
}

Mail::build() is the underused method that makes this clean. It is a public method on the MailManager that takes an array shaped exactly like one entry in config/mail.php and returns a fully wired Illuminate\Mail\Mailer instance without registering it globally. No Config::set() hacks, no leaking state into the next job. Each call gives you an isolated mailer you can discard after the send. Note the encryption value: port 587 negotiates STARTTLS, which Laravel expresses as 'tls', while port 465 uses implicit TLS from the first byte, which is 'ssl'. Getting that backwards is a common way to end up with a transport that silently refuses to connect.

How do you send a Mailable through the per-domain mailer?

Once you have the domain's mailer, you set the envelope From to the verified sending subdomain and send. The detail that matters is that the From domain must match the DKIM signing domain and the SPF-authorised relay, or the message arrives unsigned and unaligned. I resolve the domain inside the queued job so the worker fetches current settings on every attempt rather than capturing stale credentials at dispatch time.

app/Jobs/SendDomainEmail.php
<?php

namespace App\Jobs;

use App\Mail\CampaignMessage;
use App\Models\SendingDomain;
use App\Services\DomainMailerFactory;
use DateTime;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class SendDomainEmail implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 5;
    public array $backoff = [10, 30, 120, 600]; // retry transient SMTP failures

    public function __construct(
        public int $domainId,
        public string $recipient,
        public int $messageId,
    ) {}

    public function handle(DomainMailerFactory $factory): void
    {
        $domain = SendingDomain::findOrFail($this->domainId);

        // Skip anything we already suppressed via bounce/complaint webhooks.
        if ($domain->isSuppressed($this->recipient)) {
            return;
        }

        $mailer  = $factory->for($domain);
        $message = (new CampaignMessage($this->messageId))
            ->from("no-reply@{$domain->sending_subdomain}", $domain->from_name);

        $mailer->to($this->recipient)->send($message);
    }

    public function retryUntil(): DateTime
    {
        return now()->addHours(6); // stop retrying a dead relay after 6h
    }
}

How do you stop one customer from dragging down shared reputation?

Isolation is the whole product. If domain A blasts a dirty list and draws a spike of complaints, that must never touch domain B's inbox placement. I enforce it on three axes:

  • Separate DKIM keys and sending subdomains per domain, so reputation accrues to that domain's identity and nowhere else. The signing key lives in the sending_domains row and is applied through a DKIM signer on the message before it leaves the worker.
  • Per-domain queues and rate limits. Each domain sends on its own queue and is throttled through the RateLimited job middleware so a customer on a 50/min plan cannot starve the workers or trip the relay's connection caps.
  • Per-domain suppression lists. A hard bounce or spam complaint for one domain suppresses that address for that domain only, not platform-wide, because the same address can be a legitimate subscriber on another customer's domain.
  • Independent delivery metrics, so a sender score that craters for one domain raises an alert before it can ever influence another.

The rate limiter earns its keep here. I cover the primitives in my notes on rate limiting and brute-force protection, and the same named-limiter approach applies to outbound SMTP: register one limiter keyed by domain id with RateLimiter::for(), then attach it on the job.

app/Jobs/SendDomainEmail.php (middleware)
use Illuminate\Queue\Middleware\RateLimited;

public function middleware(): array
{
    // 'outbound-smtp' is a single limiter registered with RateLimiter::for()
    // in a service provider; it returns a Limit keyed ->by($job->domainId),
    // so each domain gets an independent bucket from one named limiter.
    return [new RateLimited('outbound-smtp')];
}
Server racks in a data center representing isolated per-domain SMTP relays and queues
Each customer domain gets its own queue, rate limit, and DKIM identity so reputation never bleeds across tenants.
On a sending platform, the SMTP transport is per-message state, not application config. Treat it that way or you will sign the wrong customer's mail with the wrong key.Md Raihan Hasan

What deliverability plumbing has to exist per domain?

Dynamic transports are useless if the receiving side rejects the mail. Every onboarded domain needs SPF authorising the relay, a published DKIM public key matching the signing subdomain, and a DMARC policy with a reporting address. I will not repeat the full DNS walkthrough, because I already wrote it up in my email deliverability guide for developers; the platform-specific part is that onboarding generates the DKIM keypair, stores the private half encrypted, and shows the customer the exact TXT records to publish, then blocks sending until a verification job confirms alignment.

Bounces and complaints close the loop. The relay posts a webhook on every bounce and feedback-loop complaint. The handler suppresses the address for that domain and decrements the domain's health score. The webhook itself should validate the signature and return 200 quickly, then queue the heavy work, the same discipline you would apply to any payment webhook.

What does the operational layer look like?

Everything sends through a queue, never inline in a web request, so a slow relay never blocks a user and transient failures retry with backoff instead of disappearing. I run dedicated queue workers under Supervisor with one worker pool per priority tier; if you have not hardened that yet, my write-up on queue workers with Supervisor in production is the prerequisite. After each send I record the result, the recipient domain, the relay response code, and the latency into a per-domain metrics table, which feeds the health score and the customer dashboard. That stored history is also what lets me show a customer that the deferral they are seeing is the receiver greylisting them, not my platform dropping mail.

The decision that made all of this manageable was committing early to one rule: the mailer is built fresh for every message and discarded after. The moment you cache a transport across jobs to save a few milliseconds, you reintroduce cross-tenant leakage, and on an email platform that leakage is measured in blacklisted relays and lost customers. Build it per send, isolate the queue and the keys, and let the metrics tell you which domain to throttle before a spam trap decides for you.