Sending Email from PHP: SMTP, Providers, Deliverability, and Queues

Understand why PHP's built-in mail() is unsuitable for production, how to send properly via SMTP or a transactional email provider, how to prevent header injection, what SPF/DKIM/DMARC do for deliverability, and why sending should happen from a queue with a retry strategy rather than synchronously in a request.

Sending an email from PHP looks like a one-line problem — mail($to, $subject, $body) — and that's exactly the trap. mail() technically sends email, in the same sense that shouting out a window technically sends a message: it has no authentication, no delivery confirmation, no retry, and a well-documented history of being abused for header injection. Every production system that actually needs email people receive reliably — password resets, receipts, notifications — routes around mail() entirely, using either a real SMTP relay or a transactional email API, sent from a background queue rather than inline in the HTTP request that triggered it.

Why mail() is a liability, not a shortcut

PHP's mail() function hands a message off to the server's local Mail Transfer Agent (MTA, typically sendmail or postfix) and returns almost immediately — but that return value only means 'the local MTA accepted the message for delivery attempt', not that anyone received it. There is no authentication with the receiving mail server, no TLS enforcement, no DKIM signing built in, and no way to know later whether the message bounced. Because most residential/hosting-provider IP ranges are not on any receiving mail server's trusted sender list, mail from a bare mail() call is disproportionately likely to be silently dropped into spam or rejected outright — deliverability, not just capability, is the real problem.

The more acute issue is header injection: if any part of the To, Subject, or additional-headers argument is built from unsanitized user input and contains a raw newline (\r\n), an attacker can inject arbitrary extra headers — adding Bcc: lines to send spam through your server's reputation, or Content-Type changes to alter the message structure. Modern PHP (7.0+) removed the ability to inject bare newlines via the traditional 5th ('additional headers') mail() argument in one common attack path, but user-supplied strings dropped into Subject or the To field without validation remain a real risk in code that doesn't sanitize them, which is why any mail() usage still needs explicit newline-stripping on every user-supplied header value.

Sending properly: SMTP, PHPMailer, and Symfony Mailer

The correct low-level fix is to speak SMTP directly to an authenticated relay (your provider's SMTP server, or a transactional provider's SMTP endpoint) rather than handing off to the local MTA blind. Authenticating with a username/password or API key means the receiving server can verify who's sending, and a reputable relay maintains the sender-IP reputation that a random hosting box never will. Nobody hand-rolls the SMTP protocol conversation, though — the two dominant PHP libraries are PHPMailer (a long-standing, widely deployed standalone library — construct a PHPMailer object, set isSMTP(), host/port/credentials, addAddress(), Subject, Body, call send()) and Symfony Mailer (the modern, framework-friendly approach — build a Symfony\Component\Mime\Email object and hand it to a Mailer configured from a DSN string like smtp://user:pass@host:587, with built-in support for swapping providers by changing only the DSN).

Both libraries handle the parts that are easy to get subtly wrong by hand: proper MIME multipart construction for HTML + plain-text alternative bodies, attachment encoding, STARTTLS negotiation, and — critically — header value sanitization, so user-supplied subject lines or reply-to addresses can't be used for injection the way a naive string-concatenated mail() call could be.

Transactional email providers

For anything beyond a low-volume personal project, teams use a transactional email provider — Amazon SES, SendGrid, Postmark, Mailgun, and similar services — rather than running their own SMTP relay. Conceptually these provide three things a self-hosted MTA struggles to match: pre-established sending reputation on IP ranges receiving servers already trust, built-in bounce and complaint webhooks so your application can react to a hard bounce or spam complaint programmatically, and detailed delivery analytics (opened, clicked, bounced, deferred) that raw SMTP gives you none of. Integration is either via their SMTP endpoint (drop-in replacement for the SMTP config in PHPMailer/Symfony Mailer) or their HTTP API (typically lower latency and richer error detail than SMTP's line-based protocol).

Deliverability: SPF, DKIM, and DMARC

These three DNS-based mechanisms exist because SMTP's original design lets anyone claim to send 'from' any address — deliverability today depends on proving you're authorized to send for a domain. SPF (Sender Policy Framework) is a DNS TXT record on the sending domain listing which mail servers/IP ranges are authorized to send mail claiming that domain — a receiving server checks the connecting IP against this list. DKIM (DomainKeys Identified Mail) has the sending server cryptographically sign each outgoing message with a private key, publishing the matching public key in DNS, so a receiving server can verify the message wasn't altered in transit and genuinely came from a holder of that domain's private key. DMARC (Domain-based Message Authentication, Reporting and Conformance) sits on top of both, published as a DNS policy record telling receiving servers what to do when a message fails SPF and/or DKIM alignment (quarantine, reject, or do nothing) and where to send aggregate reports about pass/fail rates — it's the mechanism that turns SPF/DKIM from advisory checks into an enforced policy.

In practice: a transactional provider handles DKIM signing for you (you just publish the DNS records they give you), you configure SPF to authorize their sending IPs alongside your own, and you publish a DMARC policy once both are working reliably — skipping any of the three is the single most common reason legitimate transactional email (password resets, receipts) lands in spam.

Queues instead of synchronous sends, bounce handling, and retries

Calling a mail-sending function inline, inside the HTTP request that triggered it (e.g. inside the request handler for 'user signs up'), ties your request's response time to a third-party network call you don't control, and a slow or failing SMTP/API call can make an otherwise-fast endpoint time out. The standard fix is to enqueue an 'send this email' job onto a message queue (Redis-backed queues, RabbitMQ, SQS, or a framework's built-in queue abstraction like Laravel's or Symfony Messenger) and return the HTTP response immediately; a separate worker process picks the job off the queue and does the actual send, decoupled from request latency.

This same queue is where retry strategy lives: a transient failure (provider rate limit, brief network blip) should be retried with exponential backoff and a capped number of attempts, while a permanent failure (invalid address, hard bounce) should not be retried at all — retrying a hard bounce repeatedly just burns sending reputation. Providers report which is which via bounce webhooks (a 'hard bounce' means the address doesn't exist and should be marked undeliverable and never emailed again; a 'soft bounce' — mailbox full, temporary server issue — is worth a bounded number of retries), and a mature system records bounce/complaint events to suppress future sends to that address entirely, since repeatedly emailing a bounced or complained-about address is itself what damages sender reputation over time.

Symfony Mailer: DSN-based configuration and sending

Changing providers (SES, SendGrid, Postmark, Mailgun) is typically just a DSN string change when using an official Symfony Mailer bridge package for that provider's HTTP API. Always set both text() and html() bodies — some mail clients and spam filters penalize HTML-only messages.

use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;

// DSN points at an SMTP relay or provider — swapping providers is a config change only.
$transport = Transport::fromDsn('smtp://username:[email protected]:587');
$mailer = new Mailer($transport);

$email = (new Email())
    ->from('[email protected]')
    ->to('[email protected]')
    ->subject('Your receipt')
    ->text('Plain-text fallback body')
    ->html('<p>HTML body</p>');

$mailer->send($email);

Code Examples

Rejects rather than silently strips: if the input contained a newline (raw or URL-encoded), that's treated as an injection attempt and the request is failed loudly instead of quietly 'fixed', which surfaces the attack rather than masking it. Any user-controlled string destined for a mail header — subject, reply-to, display name — should pass through a check like this regardless of which mail library eventually sends the message.

if ($clean !== $value) { throw new \InvalidArgumentException('Header injection attempt detected.'); }

The controller only dispatches a lightweight message object onto the bus and returns — it never blocks on network I/O to a mail provider. A separate worker consumes the queue and performs the actual send, and the messaging transport's configured retry policy (exponential backoff, max attempts) handles transient failures without the original HTTP request ever knowing a retry happened. Expected result: HTTP response returns immediately after signup; the welcome email is sent moments later by a worker process.

<?php

declare(strict_types=1);

function sanitizeHeaderValue(string $value): string
{
    // Strip CR/LF (and anything that decodes to them) — a header value must
    // never be allowed to introduce a new header line.
    $clean = str_replace(["\r", "\n", "%0d", "%0a", "%0D", "%0A"], '', $value);

    if ($clean !== $value) {
        throw new \InvalidArgumentException('Header injection attempt detected.');
    }

    return trim($clean);
}

function buildContactSubject(string $userSuppliedSubject): string
{
    $subject = sanitizeHeaderValue($userSuppliedSubject);
    return "[Contact form] {$subject}";
}
<?php

declare(strict_types=1);

final class SendWelcomeEmail
{
    public function __construct(
        public readonly string $toAddress,
        public readonly string $userName,
    ) {}
}

// In the signup controller: dispatch and return immediately.
final class SignupController
{
    public function __construct(private readonly \Symfony\Component\Messenger\MessageBusInterface $bus) {}

    public function register(string $email, string $name): void
    {
        // ... create the user record ...
        $this->bus->dispatch(new SendWelcomeEmail($email, $name));
        // Response returns now — the HTTP request does not wait on SMTP/API latency.
    }
}

// A separate worker process (php bin/console messenger:consume) handles the
// actual send, with the transport layer applying retry/backoff on failure.
final class SendWelcomeEmailHandler
{
    public function __construct(private readonly \Symfony\Component\Mailer\MailerInterface $mailer) {}

    public function __invoke(SendWelcomeEmail $message): void
    {
        $email = (new \Symfony\Component\Mime\Email())
            ->from('[email protected]')
            ->to($message->toAddress)
            ->subject('Welcome!'
…

Why password-reset emails end up in spam

The most common real-world symptom of skipping SPF/DKIM/DMARC is transactional email — password resets, order confirmations — silently landing in spam or being dropped. Because these emails are time-sensitive and users rarely check spam for them, this reads to users as 'the site is broken' rather than 'a DNS record is missing', which is why deliverability setup is treated as part of shipping the feature, not an optional followup.

mail($_POST['to'], $_POST['subject'], $body); // user input goes straight into mail() headers

$mailer = new Mailer(Transport::fromDsn($smtpDsn)); $email = (new Email())->to($validatedAddress)->subject(sanitizeHeaderValue($_POST['subject']))->text($body); $mailer->send($email);

Bare mail() with unsanitized user input in header-bound fields is both a deliverability dead end (no auth, easily spam-filtered) and a header-injection risk; routing through an authenticated SMTP/provider transport with sanitized header values fixes both problems at once.

What is the primary reason PHP's mail() function is considered unsuitable for production transactional email? A. It cannot send HTML email B. It has no authentication with receiving servers, no delivery tracking, and offers a real header-injection risk if header-bound input isn't sanitized C. It only works on Linux servers D. It requires a paid PHP extension

B. It has no authentication with receiving servers, no delivery tracking, and offers a real header-injection risk if header-bound input isn't sanitized — mail() hands off to the local MTA with no authentication, no bounce/delivery visibility, and historically has been a header-injection vector when user input reaches header-bound fields unsanitized — all reasons production systems route through authenticated SMTP or a transactional provider instead.

What does DMARC add on top of SPF and DKIM? A. It replaces the need for both SPF and DKIM B. It encrypts the email body during transit C. It publishes a policy telling receiving servers what to do when SPF/DKIM checks fail, plus a reporting mechanism D. It compresses email attachments

C. It publishes a policy telling receiving servers what to do when SPF/DKIM checks fail, plus a reporting mechanism — DMARC is a DNS policy record that tells receiving mail servers what enforcement action to take (none, quarantine, reject) when a message fails SPF and/or DKIM alignment, and where to send aggregate pass/fail reports — turning SPF/DKIM from advisory signals into an enforced, monitorable policy.

Why should sending an email typically happen via a queue rather than synchronously inside the HTTP request that triggers it? A. Queues are required by law for email B. It decouples request latency from third-party network calls and enables a proper retry strategy on failure without blocking the original request C. Synchronous sending is not supported by any PHP mail library D. Queued emails are automatically DKIM-signed

B. It decouples request latency from third-party network calls and enables a proper retry strategy on failure without blocking the original request — Enqueuing the send lets the HTTP response return immediately instead of waiting on SMTP/API latency, and puts the actual send under a worker process that can apply retry-with-backoff for transient failures — none of which is possible when the send is inline in the request.

Design (in prose or pseudocode) the flow for a 'contact us' form: what gets sanitized, what gets queued, and what happens if the provider reports a hard bounce for the configured 'from' address's auto-reply. Hint: Think through: sanitize the subject/reply-to before building the message; enqueue the send rather than calling the mailer inline in the request; a hard bounce means suppress future sends to that address, not retry.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.