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. Pointopenssl.cafileat a current one inphp.ini. Disabling verification makes the error go away and makes TLS pointless; do not do it in production.Could not instantiate mail function— themail()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
- Examples — complete scripts for every transport, including Gmail and XOAUTH2.
- SMTP Example — SMTP configuration in more depth.
- mail() Example — the local transport and its limits.
- Methods Reference — signatures for everything used here.
- Properties Reference — every configurable property in 6.x.
- Installing PHPMailer — Composer and manual routes.
