Contents

11/04/2026

PHP mail() Fails DMARC on Shared Hosting: Switch to SMTP

Short answer: on shared hosting, PHP's mail() hands your message to the server's own mail system, so it leaves under the host's identity, not your domain's. SPF passes for the wrong domain, nothing signs it with your DKIM key, and DMARC fails. Send through your site's own mailbox over authenticated SMTP instead, make the form show an error when that fails, and never fall back to mail().

Why mail() fails DMARC on shared hosting

PHP's mail() documentation is plain about what the function does: it passes the message to the program set in sendmail_path, and returns true if the message was "accepted for delivery." The manual adds, in capitals, that this does not mean it will reach its destination.

On a shared server, that local program sends from the server's IP with the server's own envelope sender. Here is what a receiver then checks:

Check What it looks at Result for mail() on a shared host
SPF The envelope sender's domain Often pass, but for the host's domain, not yours
DKIM A signature made with your domain's key None: the server has no key for your domain
DMARC Whether SPF or DKIM passed for the domain in your From: header Fail: neither one is aligned

That last row is the part most write-ups skip. RFC 7489, the DMARC specification, calls it Identifier Alignment: a pass only counts if the domain that passed matches the domain in the visible From: address. An SPF pass for server123.somehost.example does nothing for info@yoursite.com.

What it looked like on my sites

My PHP sites run on a shared DreamHost VPS. Google's DMARC report for one of them, dated 10/15/2026, showed 482 messages from that server, every one failing DMARC: SPF passed for the server's own host name, and there was no DKIM signature from the site's key. Gmail was rate-limiting the host.

The domain's DNS records were not the fault. Its SPF, DKIM and DMARC records all checked out; the mail was taking a path that none of them covered. Its DMARC policy was p=none, which asks receivers to report failures but not to act on them, so Google delivered all 482 and nothing visibly bounced. Nothing I have found says what those 482 messages were. The working guess was alert mail, and it is unverified.

It was the same cause as an incident on 08/30/2026, when a contact form on another of my sites looked fully successful and the message never arrived. mail() returned true, the relay logged it as sent, and the thanks page rendered. Gmail was rate-limiting the shared outbound host.

The half-fix: -f and an SPF include

The common answer to "PHP mail goes to spam" is two changes: pass -f you@yoursite.com as mail()'s fifth argument, which the PHP manual documents for setting the envelope sender, and add the host to your SPF record. That can get SPF aligned. It leaves two problems in place.

Approach SPF aligned DKIM for your domain Sending reputation Failure your code can see
mail() as shipped No No Shared with every site on the server None: true means "handed off"
mail() with -f and an SPF include Yes, if your SPF record covers that server No Still shared None
Authenticated SMTP through your own mailbox Yes, through your mail provider Yes, if the provider signs for your domain Your provider's outbound path The SMTP server's answer, at send time

The reputation column is the one that bit me. Gmail's sender guidelines say its IP quota is shared by every sender on an IP, and when the IP hits it, every domain sending from it stops. On shared hosting you inherit your neighbors' volume.

Those guidelines require SPF or DKIM from every sender, and SPF, DKIM and DMARC from anyone sending 5,000 or more messages a day to Gmail. A contact form is nowhere near that number. But once your domain publishes a DMARC record, receivers evaluate every message that claims your domain, form mail included.

Reading a DMARC aggregate report

A DMARC aggregate report is an XML file that receivers such as Gmail send to the address in your DMARC record's rua tag, usually daily and often compressed. You ask for them in DNS:

_dmarc.yoursite.com.  TXT  "v=DMARC1; p=none; rua=mailto:dmarc-reports@yoursite.com"

RFC 7489 defined the format in its Appendix C. In May 2026 it was obsoleted by RFC 9989, the Standards Track DMARC specification, with reports moved to RFC 9990, DMARC Aggregate Reporting. The elements you read are the same in both.

One record, field by field

Here is a record shaped like the failure above. The domain, IP and count are placeholders:

<record>
  <row>
    <source_ip>203.0.113.25</source_ip>
    <count>40</count>
    <policy_evaluated>
      <disposition>none</disposition>
      <dkim>fail</dkim>
      <spf>fail</spf>
    </policy_evaluated>
  </row>
  <identifiers>
    <header_from>yoursite.com</header_from>
  </identifiers>
  <auth_results>
    <spf>
      <domain>server123.somehost.example</domain>
      <result>pass</result>
    </spf>
  </auth_results>
</record>
Element What it tells you
source_ip, count Which server sent how many messages claiming your domain. Your web server's IP here means your forms
policy_evaluated/spf, /dkim The DMARC verdict: did each check pass and align
auth_results/spf The raw SPF result and which domain it was for
auth_results/dkim The raw DKIM result and signing domain. Missing entirely means unsigned

The tell is a raw SPF pass beside a DMARC spf of fail. That combination means SPF passed for someone else's domain.

A reader for the report

Reports pile up as attachments. This prints one line per record, reading .xml or .xml.gz through PHP's zlib stream wrapper (unzip a .zip first). It needs PHP 8.0 or later:

<?php
declare(strict_types=1);

// Usage: php read-dmarc-report.php report.xml   (or report.xml.gz)
$path = $argv[1] ?? exit("Usage: php read-dmarc-report.php <report.xml[.gz]>\n");
$source = str_ends_with($path, '.gz') ? 'compress.zlib://' . $path : $path;
$xml = simplexml_load_string((string) file_get_contents($source));
if ($xml === false) {
    exit("Not a readable XML report: {$path}\n");
}

foreach ($xml->record as $record) {
    $row = $record->row;
    $spf = $record->auth_results->spf;
    $dkim = $record->auth_results->dkim;
    printf(
        "%-15s x%-5s from=%s | DMARC spf=%s dkim=%s | raw SPF %s for %s | raw DKIM %s\n",
        $row->source_ip,
        $row->count,
        $record->identifiers->header_from,
        $row->policy_evaluated->spf,
        $row->policy_evaluated->dkim,
        $spf->result,
        $spf->domain,
        $dkim->count() > 0 ? "{$dkim->result} for {$dkim->domain}" : 'none (unsigned)'
    );
}

Against the record above it prints raw SPF pass for server123.somehost.example | raw DKIM none (unsigned). It reports the first DKIM signature only, which is enough to spot an unsigned stream.

What my own domains publish

A report only arrives if the record asks for one. I looked up each domain's DMARC record on 10/26/2026 with nslookup:

Domain Policy Where reports go
restorationadvertising.com p=none An address on the domain itself
brandager.com p=none An address at Brevo
nextdayvideos.com p=none An address at Brevo
belchamber.us p=none Nowhere: no rua tag
prolificfutility.com p=none Nowhere: no rua tag
tools.belchamber.us p=reject Nowhere. It sends no mail, and its SPF record is -all

Two of my sending domains ask for no reports, so a mail() problem there would never have shown up in one. I saw the 482 only because the domain that had them asked. All five of the sending domains also publish an SPF record that includes DreamHost's netblocks and MailChannels' relay, which is why relayed test sends can pass. None of the five sending domains is past p=none yet. Moving a domain to a stricter policy is a decision to make after the reports show only aligned mail, not before.

Send through the site's own mailbox over SMTP

PHPMailer sends over authenticated SMTP with a handful of settings, and it has two defaults that matter here. Its default transport is mail(), so forgetting isSMTP() puts you right back where you started. Its default timeout is 300 seconds, which leaves a visitor staring at a spinning submit button for five minutes when the mail server is down.

Install it with composer require phpmailer/phpmailer. The login is the site's own mailbox, and the password comes from the environment, never from the code or the repository:

<?php
declare(strict_types=1);

use PHPMailer\PHPMailer\Exception as MailerException;
use PHPMailer\PHPMailer\PHPMailer;

final class MailNotConfiguredException extends RuntimeException
{
}

final class SmtpSettings
{
    private const REQUIRED = ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USERNAME', 'SMTP_PASSWORD'];

    public function __construct(
        public readonly string $host,
        public readonly int $port,
        public readonly string $username, // the site's own mailbox, e.g. info@example.com
        public readonly string $password,
    ) {
    }

    public static function fromEnvironment(): self
    {
        $values = [];
        foreach (self::REQUIRED as $name) {
            $value = getenv($name);
            if ($value === false || $value === '') {
                throw new MailNotConfiguredException("{$name} is not set; refusing to send.");
            }
            $values[$name] = $value;
        }

        return new self(
            $values['SMTP_HOST'],
            (int) $values['SMTP_PORT'],
            $values['SMTP_USERNAME'],
            $values['SMTP_PASSWORD'],
        );
    }
}

final class ContactMailer
{
    public function __construct(
        private readonly SmtpSettings $smtp,
        private readonly string $deliverTo,
    ) {
    }

    /** @throws MailerException when the SMTP server does not take the message */
    public function send(string $visitorEmail, string $subject, string $body): void
    {
        $mail = new PHPMailer(true);   // throw on failure instead of returning false
        $mail->isSMTP();               // PHPMailer's default transport is PHP mail()
        $mail->Host = $this->smtp->host;
        $mail->Port = $this->smtp->port;
        $mail->SMTPAuth = true;
        $mail->SMTPSecure = $this->smtp->port === 465
            ? PHPMailer::ENCRYPTION_SMTPS
            : PHPMailer::ENCRYPTION_STARTTLS;
        $mail->Username = $this->smtp->username;
        $mail->Password = $this->smtp->password;
        $mail->Timeout = 15;           // the default is 300 seconds

        $mail->setFrom($this->smtp->username);  // your domain, the mailbox you logged in as
        $mail->addAddress($this->deliverTo);
        $mail->addReplyTo($visitorEmail);       // never the From: address
        $mail->Subject = $subject;
        $mail->Body = $body;

        $mail->send();
    }
}

A sender per site, or one for all? Mail only I receive, such as alerts, can share a sender. Mail a visitor receives, such as a receipt or a confirmation, must be signed as the site it came from, or it fails alignment the same way. My standing rule since 10/19/2026 is that each site sends everything from its own mailbox over SMTP, never from the shared host.

The visitor's address goes in Reply-To, never From. Putting $_POST['email'] in From is the other classic contact-form DMARC failure: you are then claiming to be the visitor's domain, and Gmail's guidelines specifically warn against impersonating Gmail From: headers.

The same idea in Symfony Mailer

In Symfony Mailer, the equivalent is an smtp:// DSN in MAILER_DSN. Avoid native://default and sendmail://default: both hand the message to the local sendmail program, which is the mail() path again. Symfony's own docs also warn that native:// with sendmail -t gives you no error reporting.

Never fall back to mail()

The tempting version is try { smtp } catch { mail() }. Don't write it. The fallback sends through the exact path that is failing DMARC, returns true, and shows the thanks page, so the one failure you could have seen becomes one you can't.

Let the exception stop the request instead:

<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/ContactMailer.php';

use PHPMailer\PHPMailer\Exception as MailerException;

try {
    $mailer = new ContactMailer(SmtpSettings::fromEnvironment(), 'info@example.com');
    $mailer->send(
        (string) ($_POST['email'] ?? ''),
        'Website inquiry',
        (string) ($_POST['message'] ?? ''),
    );
} catch (MailerException | MailNotConfiguredException $e) {
    error_log('Contact form NOT sent: ' . $e->getMessage());
    http_response_code(503);
    exit('Your message was not sent. Please try again in a few minutes.');
}

header('Location: /contact/thanks/', true, 303);

The thanks page is now something the code earns. A missing setting, a refused login, an unreachable server or a malformed visitor address all end on the error, with a line in the log.

My own mail package enforces the rule with a test, not a habit. Its 28 checks run against a scripted fake SMTP server, and when I deliberately let a missing login fall back to mail(), 6 of the 28 failed. A rule that no test breaks on is one that gets lost the first time someone tidies the code.

Then search the codebase for every other mail( call. When I moved my sites' forms to their own mailboxes on 10/16/2026, checkout receipts, draft resume links and a plan builder were still on mail() at the end of that session. A contact form is rarely the only sender on a site. They followed within hours: the sites' repositories show receipts and sale alerts moved to SMTP that evening, with the plan builder's mail, after the contact relay and the lock alerts had moved earlier the same day. On 10/26/2026 the only mail( call left in my sites' PHP is the default branch of the shared mailer, and in SMTP mode that branch refuses to send. I read that in the code. I have not re-checked the live servers.

This is the same trap as curl exiting 0 on a 404 and a mysqldump backup that said it worked: the call that reports success is not the one doing the work.

Verify SPF, DKIM and DMARC pass

Send a test from the form to a Gmail address, open it, and use Show original, as Gmail's help page on full headers describes. The Authentication-Results header should read along these lines, with your own domain in place of the placeholder:

dkim=pass header.d=yoursite.com
spf=pass smtp.mailfrom=yoursite.com
dmarc=pass header.from=yoursite.com

All three must name your domain. dkim=pass for some other domain is the alignment problem in a different coat.

What my test sends showed

After the switch, test sends from two of my sites passed all three: DKIM signed as the site's own domain, SPF pass, DMARC aligned with the visible From address. The headers showed them relayed through MailChannels rather than from the shared server Google had been rate-limiting. A third site's alert email then sent as its own info@ address with all three passing.

Sent is not received

A passing header proves one message arrived once. The next aggregate reports should show your form mail coming from the mail provider's servers, aligned, and the shared server's IP dropping out.

The lasting lesson from 08/30/2026 is that mail() can only ever prove "sent." Only reading the receiving mailbox proves "received," which is why my sites' forms-hardening work added a daily check that submits a marked test inquiry and then looks for it in the inbox.

Aaron Belchamber

Let my Claude help your Claude

If you want a second pair of eyes on how your own setup uses AI-assisted development, that's what this is: me, personally, for AI specifically. Broader business growth and technology consulting is Brandager's own team.

Book a free 30-minute consult

Researched and drafted with AI, then reviewed and edited by the site's editors.

Filed under