Send an email with PHP: SMTP, DKIM and deliverability

Sending email is a very common job for a web application: a welcome message when a user creates an account on your site, newsletters to your registered users, the plain contact form on the site, and so on. You can use the built-in PHP function

Send an email with PHP: SMTP, DKIM and deliverability
Quick answer

Do not use mail(): with no local mail server it returns false, and its messages no longer get past the filters. Send over authenticated SMTP with PHPMailer or Symfony Mailer, and publish SPF, DKIM and DMARC on the domain. The From has to belong to your domain, the visitor's address goes in Reply-To.

Sending an email from PHP is easy. Getting it into the inbox is a great deal harder. The mail() function, the one almost every tutorial reaches for first, fails on both counts in 2026: it does nothing without a local mail server, and when it does work, its messages land in the spam folder. This article covers what actually works, and why.

Why mail() is no longer enough

The mail() function does not speak SMTP. It hands the job to a local binary, sendmail, named by the sendmail_path directive. If there is no mail server on the machine, nothing happens.

php
<?php
echo ini_get('sendmail_path'), "\n";  // /usr/sbin/sendmail -t -i

$ok = mail('destinataire@example.com', 'Test', 'Message de test');
var_dump($ok);

On a stock PHP 8.5 container with no MTA installed:

code
sh: 1: /usr/sbin/sendmail: not found
Warning: mail(): Sendmail exited with non-zero exit code 127
bool(false)

That is the default state of every Docker container, of most development images, and of a fair share of hosting plans. The code reports nothing explicit: it returns false, and the return value is almost always ignored.

Even when an MTA is present, true does not mean “message delivered”. It means “the message was accepted by the local program”. What happens next, refusal by the remote server, quarantine, a silent drop, is invisible from PHP.

The real problem: authenticating the domain

Since February 2024, Gmail and Yahoo have required full authentication from bulk senders. Microsoft followed in 2025. A message that is neither signed nor aligned no longer arrives, or arrives in the spam folder. Three mechanisms work together.

SPF

A TXT DNS record on your domain, listing the servers allowed to send on its behalf.

code
exemple.com.  IN  TXT  "v=spf1 include:_spf.mon-fournisseur.com -all"

The trailing -all means “any other server is illegitimate”. A ~all is more permissive and is enough while you are setting things up, but -all is the target.

DKIM

A cryptographic signature added to the message headers. The recipient fetches the public key from DNS and checks that the message has not been altered and really does come from the domain it claims.

bash
# Generate the key pair
openssl genrsa -out dkim-prive.pem 2048
openssl rsa -in dkim-prive.pem -pubout -out dkim-public.pem

The public key is published at <selecteur>._domainkey.exemple.com:

code
gk2026._domainkey.exemple.com.  IN  TXT  "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."

The private key stays on the server, outside the Git repository, readable only by the user PHP runs as.

DMARC

The policy that tells the recipient what to do when SPF and DKIM fail, and where to send the reports.

code
_dmarc.exemple.com.  IN  TXT  "v=DMARC1; p=none; rua=mailto:dmarc@exemple.com; adkim=s; aspf=s"

Always start with p=none, which rejects nothing but does produce reports. Read those reports for a few weeks, fix what they show, then move to p=quarantine, then p=reject. Going straight to p=reject blocks your own messages.

The one thing those three records do not settle on their own is alignment: the address in the visible From: has to belong to the same domain as the one validated by SPF and signed by DKIM. A contact form that puts the visitor’s address in From: breaks that alignment and fails DMARC on the visitor’s own domain. The right shape is to send from your domain and put the visitor in Reply-To:.

Sending over authenticated SMTP with PHPMailer

PHPMailer handles SMTP, encoding, attachments and DKIM signing. It is the most widely used library of the two, and it is maintained.

bash
composer require phpmailer/phpmailer
src/envoi.php
<?php

declare(strict_types=1);

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

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception as MailException;

$mail = new PHPMailer(true); // true: errors throw an exception

try {
    $mail->isSMTP();
    $mail->Host       = getenv('SMTP_HOST') ?: throw new RuntimeException('SMTP_HOST manquant');
    $mail->Port       = (int) (getenv('SMTP_PORT') ?: 587);
    $mail->SMTPAuth   = true;
    $mail->Username   = getenv('SMTP_USER') ?: '';
    $mail->Password   = getenv('SMTP_PASS') ?: '';
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // 587
    $mail->CharSet    = PHPMailer::CHARSET_UTF8;
    $mail->Timeout    = 15;

    // DKIM signature
    $mail->DKIM_domain   = 'exemple.com';
    $mail->DKIM_private  = '/chemin/hors/depot/dkim-prive.pem';
    $mail->DKIM_selector = 'gk2026';
    $mail->DKIM_identity = 'contact@exemple.com';

    $mail->setFrom('contact@exemple.com', 'Exemple');
    $mail->addAddress('lecteur@example.com', 'Lecteur');
    $mail->addReplyTo('contact@exemple.com');

    $mail->Subject = 'Confirmation de votre inscription';
    $mail->isHTML(true);
    $mail->Body    = '<h1>Bienvenue</h1><p>Votre compte est actif.</p>';
    $mail->AltBody = "Bienvenue\nVotre compte est actif.";

    $mail->send();
} catch (MailException $e) {
    error_log('Envoi impossible : ' . $mail->ErrorInfo);
    throw new RuntimeException('Envoi impossible', previous: $e);
}

The plain-text version (AltBody) is not optional. An HTML-only message is a negative signal for the filters, and some clients show nothing but the text part.

On our test environment the message goes out in 148 ms and arrives with the following header:

code
DKIM-Signature: v=1; d=gekkode.com; s=gk2026;
 a=rsa-sha256; q=dns/txt; t=1788378681; c=relaxed/simple;
 h=Date:To:From:Reply-To:Subject:Message-ID:X-Mailer:MIME-Version:Content-Type;
 i=contact@gekkode.com;
 bh=amLL8hgBhL3CiuV3ieZS24St8xKkkSLjcdpx2cHdvoI=;

The same thing with Symfony Mailer

If the project already uses Symfony components, Symfony Mailer fits in better and handles the queue for you.

bash
composer require symfony/mailer
php
<?php

declare(strict_types=1);

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

$dsn       = getenv('MAILER_DSN'); // smtp://utilisateur:motdepasse@hote:587
$transport = Transport::fromDsn($dsn);
$mailer    = new Mailer($transport);

$email = (new Email())
    ->from('contact@exemple.com')
    ->to('lecteur@example.com')
    ->replyTo('contact@exemple.com')
    ->subject('Confirmation de votre inscription')
    ->text("Bienvenue\nVotre compte est actif.")
    ->html('<h1>Bienvenue</h1><p>Votre compte est actif.</p>');

$mailer->send($email);

Testing without polluting real mailboxes

Firing test messages at real addresses damages the domain’s reputation. A test SMTP server intercepts everything and offers a web interface. Mailpit does exactly that, in one container.

docker-compose.yml
services:
  mail:
    image: axllent/mailpit:latest
    ports:
      - "8025:8025"   # web interface
      - "1025:1025"   # SMTP
    environment:
      MP_SMTP_AUTH_ACCEPT_ANY: "1"
      MP_SMTP_AUTH_ALLOW_INSECURE: "1"

The application then points at smtp://mail:1025, and every message opens at http://localhost:8025, complete with its raw source. It is the only way to check that a DKIM header really is there before going to production.

The contact form, a special case

Three mistakes come up again and again, and every one of them has consequences.

php
// What not to do
$mail->setFrom($_POST['email']);                    // breaks SPF and DKIM
$mail->Subject = $_POST['sujet'];                   // header injection
$mail->addAddress($_POST['destinataire']);          // open relay

// The right way
$expediteur = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($expediteur === false) {
    throw new InvalidArgumentException('Adresse invalide');
}

$mail->setFrom('contact@exemple.com', 'Formulaire du site'); // always your domain
$mail->addReplyTo($expediteur);                              // the reply goes to the visitor
$mail->addAddress('contact@exemple.com');                    // fixed recipient
$mail->Subject = 'Message du formulaire de contact';         // fixed subject
$mail->Body    = nl2br(htmlspecialchars(
    (string) ($_POST['message'] ?? ''),
    ENT_QUOTES | ENT_SUBSTITUTE,
    'UTF-8',
));

Header injection deserves a word of its own. A line break slipped into a field passed to mail() used to let anyone add hidden recipients and turn the form into a spam relay. We sent the subject "Sujet normalrnBcc: victime@example.com" through both libraries: neither throws an exception, and neither creates the Bcc header. They neutralise the line break when encoding.

code
PHPMailer 7.1.1  -> Subject: Sujet normalBcc: victime@example.com
Symfony Mailer   -> Subject: Sujet =?utf-8?Q?normal?=
Dans les deux cas : aucun en-tête Bcc dans le message livré.

The outcome is safe, but nothing tells you about it: the subject arrives mangled without a single warning. Validating the fields upstream is still necessary, and assembling the headers yourself is still to be avoided.

When to move to a sending service

An SMTP server you set up yourself on a VPS works, right up to the day its IP address ends up on a blocklist. Transactional sending providers, Brevo, Postmark, Amazon SES, Mailgun, look after IP reputation, feedback loops and DMARC reports. You use them either over plain SMTP, in which case the code above does not change, or through an API.

This is the ground covered by Email Impact, the PrestaShop module we publish for a shop’s transactional emails: templates, deliverability and send tracking. The mechanisms described here, authenticated SMTP, an aligned From:, a DKIM signature, are exactly the ones it rests on, and the same DNS settings apply whatever the sending tool.

Sending a lot of messages

A loop that sends a thousand messages inside an HTTP request ends in a timeout. You need a queue: the request records the messages to send, and a scheduled task works through them in batches.

php
// A batch send, called from a scheduled task
$parLot = 30;
$aTraiter = $file->prochains($parLot);

foreach ($aTraiter as $message) {
    try {
        $mailer->send($message->email());
        $file->marquerEnvoye($message->id);
    } catch (Throwable $e) {
        $file->marquerEchec($message->id, $e->getMessage());
    }
    usleep(200_000); // stay within the provider's rate limits
}

A failure on one message must never interrupt the batch. And every message on a mailing list has to carry a List-Unsubscribe header with a working unsubscribe link: that has been a requirement from the big providers since 2024, not a courtesy.

In short

  • mail(): for a local script nobody depends on, and nothing else.
  • Authenticated SMTP through PHPMailer or Symfony Mailer, with SPF, DKIM and DMARC published: the bare minimum for being delivered.
  • A test SMTP server in development, never real addresses.
  • A transactional sending service as soon as the volume goes past a few messages a day.

To attach a file uploaded by a visitor, validate it first as described in uploading a file in PHP. To drive a sending service through its API rather than over SMTP, see making a cURL request in PHP. Both libraries mentioned here install with Composer.

See also validating an email address and the web development hub.

Common errors

mail() with no mail server Returns false with Sendmail exited with non-zero exit code 127. That is the default case for every Docker container and for a fair share of hosting plans.
A true return from mail() does not mean “delivered” It means “accepted by the local program”. A refusal by the remote server is invisible from PHP.
From set to the visitor’s address Breaks SPF and DKIM alignment, and fails DMARC on the visitor’s domain. The sender is your domain, the visitor goes in Reply-To.
DMARC published straight at p=reject Blocks your own messages. Start with p=none, read the reports, then tighten.
HTML-only message A negative signal for the filters. Always provide a text version in AltBody.
Header injection The subject "SujetrnBcc: victime@example.com" creates no Bcc header with PHPMailer or Symfony Mailer, but neither of them throws an exception: the subject arrives mangled with no warning.
Bulk sending inside an HTTP request Ends in a timeout. You need a queue worked through in batches from a scheduled task.

DélivrabilitéDKIMEmailPHPSMTP

Damien Flandrin Web developer since 2010, creator of Gekkode and Email Impact. Every article is tested on a real project before publication. Contact
Newsletter

New tests, tutorials and projects, by e-mail.

Reproducible tests, versioned code, dated results. Never any spam.