PHPMailer Tutorial: How to Send Email in PHP

0

This tutorial takes you from an empty project to a reliable, authenticated email send in PHP using PHPMailer 6.x — the version currently maintained at github.com/PHPMailer/PHPMailer. Each step builds on the previous one, and the failure modes are covered as they come up rather than left to a troubleshooting appendix.

If you only want a snippet to paste, the examples page has one per transport.

1. Why not just use mail()?

PHP’s mail() function works, but it gives you almost nothing: no SMTP authentication, no TLS, no MIME handling, and no useful error reporting. It returns true the moment the local mail transfer agent accepts the message, so a mail that the MTA silently discards a second later is indistinguishable from a delivered one.

PHPMailer wraps both mail() and SMTP behind one API and handles the parts that are tedious and easy to get wrong: MIME multipart structure, base64 and quoted-printable encoding, header folding, character sets, attachments, inline images, and TLS negotiation.

2. Install with Composer

composer require phpmailer/phpmailer

PHPMailer 6.x runs on PHP 5.5 and above, including current releases. Two extensions matter in practice: ext-openssl for any TLS connection, and ext-mbstring if your subjects or bodies contain non-ASCII characters. Check both:

php -m | grep -E 'openssl|mbstring'

If Composer is not an option, installing PHPMailer manually covers requiring the class files directly.

3. Load the library

<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;

require 'vendor/autoload.php';

Three imports, three purposes. PHPMailer is the mailer itself and also holds the encryption and charset constants. SMTP holds the debug-level constants. Exception is PHPMailer’s own exception class — importing it matters, because catching the global \Exception instead will also swallow unrelated errors.

4. Create an instance, with exceptions on

$mail = new PHPMailer(true);

That true is the single most useful character in this tutorial. Without it, send() returns false on failure and it is entirely up to you to check — which is how applications end up silently not sending email for weeks. With it, failures throw and you find out immediately.

5. A minimal message

try {
    $mail->setFrom('from@example.com', 'Your App');
    $mail->addAddress('recipient@example.com', 'Recipient Name');

    $mail->Subject = 'Hello from PHPMailer';
    $mail->Body    = 'This is a plain-text message body.';

    $mail->send();
    echo 'Message sent.';
} catch (Exception $e) {
    echo 'Send failed: ' . $mail->ErrorInfo;
}

Note what is being printed in the catch block: $mail->ErrorInfo, not $e->getMessage(). The exception message is usually generic — SMTP Error: Could not authenticate. — while ErrorInfo carries the server’s own response, which is what actually tells you why.

This send used the mail() transport, because that is PHPMailer’s default. It will work on a server with a configured MTA and do nothing useful on one without.

6. Switch to authenticated SMTP

This is the step that turns a script that sometimes sends email into one that reliably does.

$mail->isSMTP();
$mail->Host       = 'smtp.example.com';
$mail->SMTPAuth   = true;
$mail->Username   = 'user@example.com';
$mail->Password   = 'your-password';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port       = 587;

The port and the encryption constant must agree:

Port Constant Behaviour
587 ENCRYPTION_STARTTLS Plain connection upgraded to TLS. Use this unless told otherwise.
465 ENCRYPTION_SMTPS TLS from the first byte.

Mismatched pairs account for most SMTP connect() failed reports: 587 with ENCRYPTION_SMTPS hangs until the timeout expires, 465 with ENCRYPTION_STARTTLS fails at once.

Before debugging PHP, confirm the port is even reachable — many hosts and cloud providers block outbound SMTP by default:

openssl s_client -starttls smtp -connect smtp.example.com:587

Keep credentials out of your code

$mail->Username = getenv('SMTP_USER');
$mail->Password = getenv('SMTP_PASS');

Hard-coded SMTP passwords end up in version control, and a leaked sending credential means someone else sends mail as your domain. Read them from the environment.

7. HTML with a plain-text alternative

$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->isHTML(true);
$mail->Subject = 'Your order has shipped';
$mail->Body    = '<h1>On its way</h1><p>Your order shipped today.</p>';
$mail->AltBody = "On its way\n\nYour order shipped today.";

Always set AltBody. Some clients render it instead of the HTML, and a missing plain-text part is a small negative signal to spam filters — a message with only an HTML part looks more like bulk mail than a transactional one.

Set CharSet to UTF-8 whenever accents or non-Latin characters can appear. The default is ISO-8859-1 and it will corrupt them.

If your HTML lives in a file and references local images, msgHTML() does the whole job — it embeds the images and generates AltBody from the markup:

$mail->msgHTML(file_get_contents('templates/shipped.html'), __DIR__ . '/templates');

8. Attachments and inline images

// A file from disk
$mail->addAttachment('/path/to/invoice.pdf', 'invoice.pdf');

// Generated in memory, never written to disk
$mail->addStringAttachment($pdfBytes, 'receipt.pdf', 'base64', 'application/pdf');

// An image referenced from the HTML body
$mail->addEmbeddedImage('/path/to/logo.png', 'logo');
$mail->Body = '<img src="cid:logo" alt="Logo"><p>Hello.</p>';

Inline images work through content IDs: the second argument to addEmbeddedImage() is the cid you then reference in the markup. Embedding raises the message size and some clients block images by default — for a logo it is fine, for a gallery use links.

9. Recipients, CC, BCC, reply-to

$mail->addAddress('primary@example.com', 'Primary');
$mail->addCC('manager@example.com');
$mail->addBCC('archive@example.com');
$mail->addReplyTo('support@example.com', 'Support');

Validate addresses that came from user input before adding them, so one bad value does not abort the whole operation:

if (!PHPMailer::validateAddress($email)) {
    throw new InvalidArgumentException("Invalid address: $email");
}

This checks syntax only. No library can tell you from the address alone whether the mailbox exists.

10. Turn on debugging when something fails

$mail->SMTPDebug = SMTP::DEBUG_SERVER;

This prints the real SMTP conversation — greeting, capabilities, STARTTLS, authentication, envelope, data, response. In almost every case the failing line is obvious on the first run.

Level Shows
DEBUG_OFF Nothing. Production setting.
DEBUG_CLIENT Your commands only.
DEBUG_SERVER Commands and server replies. Start here.
DEBUG_CONNECTION Adds connection-level detail.
DEBUG_LOWLEVEL Raw traffic. Rarely needed.

Debug output goes straight to standard output, so it will corrupt a JSON response or an HTTP header. Never leave it on in production. To capture it into a log instead:

$mail->SMTPDebug   = SMTP::DEBUG_SERVER;
$mail->Debugoutput = function ($str, $level) {
    error_log("PHPMailer [$level] $str");
};

What the common failures actually mean

  • SMTP connect() failed — the TCP connection never completed. Wrong host or port, an outbound firewall, or the port/encryption mismatch from step 6.
  • Could not authenticate — credentials rejected. On Gmail this is nearly always a regular password used where an App Password is required.
  • certificate verify failed — the server certificate could not be validated, usually a stale or missing CA bundle. Point openssl.cafile at a current one in php.ini. Disabling verification makes the error go away and makes TLS pointless; do not do it in production.
  • Could not instantiate mail function — the mail() transport has no working MTA behind it. Switch to SMTP.
  • Sends fine, lands in spam — not a PHPMailer problem. See step 12.

11. Sending more than one message

Reuse the connection instead of reconnecting per message, and clear the recipients between sends:

$mail->SMTPKeepAlive = true;

foreach ($recipients as $r) {
    try {
        $mail->addAddress($r['email'], $r['name']);
        $mail->Body = renderBody($r);
        $mail->send();
    } catch (Exception $e) {
        error_log("Failed for {$r['email']}: {$mail->ErrorInfo}");
    } finally {
        $mail->clearAddresses();
    }
}

$mail->smtpClose();

The finally block is the important part. Forget clearAddresses() and recipients accumulate, so the tenth message goes to all ten people. Catching per iteration also means one bad address does not stop the batch.

12. Deliverability is DNS, not PHP

Once the code works, whether messages arrive stops being a PHP question. Receiving providers decide based on records published for your sending domain:

  • SPF — a TXT record listing which servers may send for the domain.
  • DKIM — a public key in DNS so receivers can verify the message was signed by you and not altered.
  • DMARC — a policy telling receivers what to do when SPF and DKIM fail, plus where to send reports.

Missing DKIM is the usual reason a perfectly formed message gets filtered. If your host does not sign outgoing mail, PHPMailer can sign it itself:

$mail->DKIM_domain   = 'example.com';
$mail->DKIM_selector = 'phpmailer';
$mail->DKIM_private  = '/path/to/dkim_private.pem';
$mail->DKIM_identity = $mail->From;

Publish the matching public key at phpmailer._domainkey.example.com. Keep the private key outside the web root.

13. Keep the configuration in one place

Repeating fifteen lines of SMTP setup in every script that sends email guarantees they will drift apart. Subclass once instead:

class AppMailer extends PHPMailer
{
    public function __construct(bool $exceptions = true)
    {
        parent::__construct($exceptions);

        $this->isSMTP();
        $this->Host       = getenv('SMTP_HOST');
        $this->SMTPAuth   = true;
        $this->Username   = getenv('SMTP_USER');
        $this->Password   = getenv('SMTP_PASS');
        $this->SMTPSecure = self::ENCRYPTION_STARTTLS;
        $this->Port       = 587;
        $this->CharSet    = self::CHARSET_UTF8;

        $this->setFrom(getenv('MAIL_FROM'), getenv('MAIL_FROM_NAME'));
    }
}

Every send then starts with new AppMailer(). Extending PHPMailer goes further into this pattern.

Next steps

sunshyne works on technical SEO and email deliverability for French-speaking markets, and runs the digital consultancy at sunshyne.ch. Most of that work sits where the two overlap. On the SEO side: redirect mapping, crawl and indexation diagnostics, server log analysis, and recovering domains whose history has damaged them. On the email side: the authentication layer — SPF, DKIM and DMARC — sender reputation, and the reasons a technically valid message still gets filtered. These PHPMailer pages exist because the second half of that work keeps returning to the same questions: which transport to use, why an SMTP connection fails, and why a correctly formed message is rejected anyway. The examples here are the ones worth keeping after answering those questions more than once.

Comments are closed.