Lesson 31 • Advanced
Building Email Systems 📧
By the end of this lesson you'll send authenticated SMTP email from PHP with PHPMailer — HTML and plain-text, with attachments — queue large sends so they never block a request, and set up SPF, DKIM, and DMARC so your mail actually reaches the inbox.
What You'll Learn in This Lesson
- Explain why the built-in mail() function is unreliable
- Send authenticated, TLS-encrypted email with PHPMailer
- Send both HTML and a plain-text fallback in one message
- Attach files and add CC / BCC recipients
- Queue bulk sends so they never block the web request
- Set up SPF, DKIM, and DMARC for reliable deliverability
composer require phpmailer/phpmailer and a real SMTP account, so they won't run in an online sandbox — run them on your own server. The plain-PHP queue example runs anywhere, including the free onecompiler.com/php. New to PHP? Start with the Introduction to PHP lesson.mail() is dropping an unsigned note in any old box — no return address, so the post office (spam filter) distrusts it. SMTP auth is showing ID at the counter. SPF registers which post offices may send for you, DKIM is a tamper-proof wax seal, and DMARC is your written instruction for what to do with suspicious letters claiming to be from you. And when you've got 10,000 letters, you don't make every customer wait at the counter — you drop them in the outgoing tray (a queue) and the mail room sends them later.1️⃣ Why mail() Is Not Enough
PHP ships with a mail() function, and it looks tempting because it's one line. But it hands your message to the server's local mail program with no authentication (no proof of who you are) and no encryption. Worse, it returns true the moment the message is handed off locally — that is not the same as the email being delivered. Gmail and Outlook routinely send mail() messages straight to spam, and you get no error explaining why.
<?php
// The OLD way: PHP's built-in mail() function.
// It hands your message to the server's local "sendmail" program.
// It looks simple, but it is the #1 reason emails land in spam.
$to = "alice@example.com";
$subject = "Welcome!";
$body = "Thanks for signing up.";
$headers = "From: noreply@yoursite.com\r\n";
// mail() returns true only if the message was HANDED OFF locally —
// NOT if it was actually delivered. There is no authentication,
// no encryption, and no error detail. That is the whole problem.
if (mail($to, $subject, $body, $headers)) {
echo "Handed to the local mail system (delivery NOT guaranteed).\n";
} else {
echo "mail() failed.\n";
}
?>Handed to the local mail system (delivery NOT guaranteed).The takeaway: a successful mail() call tells you almost nothing. For real, reliable email you need a proper SMTP connection — and that's what PHPMailer gives you.
2️⃣ Authenticated SMTP with PHPMailer
SMTP (Simple Mail Transfer Protocol) is the language mail servers speak. PHPMailer is the most popular PHP library for it — install it once with composer require phpmailer/phpmailer. It connects to a real mail server with a username and password (authentication) over a TLS-encrypted channel, so your mail is trusted and private. Notice the password comes from getenv() — never written in the code — and every send is wrapped in try/catch so a failure is handled, not ignored.
<?php
// The RIGHT way: PHPMailer talks SMTP directly to a real mail server,
// with a username/password (auth) and TLS (encryption).
// Install once: composer require phpmailer/phpmailer
require __DIR__ . "/vendor/autoload.php"; // Composer's autoloader
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
$mail = new PHPMailer(true); // true = throw an Exception when something fails
try {
// 1) Connect to an AUTHENTICATED, ENCRYPTED SMTP server.
$mail->isSMTP(); // use SMTP, not mail()
$mail->Host = "smtp.sendgrid.net"; // your provider's host
$mail->SMTPAuth = true; // turn on login
$mail->Username = "apikey"; // provider username
$mail->Password = getenv("SMTP_PASSWORD"); // 👈 from the environment
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // upgrade to TLS
$mail->Port = 587; // 587 = submission + STARTTLS
// 2) Who it's from and who it's going to.
$mail->setFrom("hello@yoursite.com", "Your App");
$mail->addAddress("alice@example.com", "Alice"); // call again for more
// 3) Send BOTH an HTML version and a plain-text fallback.
$mail->isHTML(true);
$mail->Subject = "Welcome to Your App";
$mail->Body = "<h1>Welcome!</h1><p>We're glad you're here.</p>";
$mail->AltBody = "Welcome! We're glad you're here."; // shown if HTML is blocked
$mail->send();
echo "Email sent.\n"; // only printed if delivery to SMTP succeeded
} catch (Exception $e) {
// ALWAYS handle the failure — log it, don't crash the request.
echo "Email NOT sent: {$mail->ErrorInfo}\n";
}
?>Email sent.composer require phpmailer/phpmailer) and a real SMTP account, so it won't run in an online sandbox — run it on your own server. Symfony Mailer (used by Laravel) does the same job with a DSN like smtp://user:pass@host:587.Two details that matter for the inbox: isHTML(true) sends a styled HTML message, and AltBody provides a plain-text fallback for clients that block HTML. Always set both — HTML-only mail is more likely to be flagged as spam.
3️⃣ Attachments, CC & BCC
Transactional email often carries a file — an invoice, a receipt, a ticket. PHPMailer makes this one call: addAttachment(path, name) reads a file from disk and gives it a clean display name. You can also copy other people in: addCC() adds a visible copy, while addBCC() adds a hidden one the other recipients can't see.
<?php
require __DIR__ . "/vendor/autoload.php";
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = "smtp.sendgrid.net";
$mail->SMTPAuth = true;
$mail->Username = "apikey";
$mail->Password = getenv("SMTP_PASSWORD");
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->setFrom("billing@yoursite.com", "Billing");
$mail->addAddress("alice@example.com");
$mail->addCC("manager@yoursite.com"); // visible copy
$mail->addBCC("audit@yoursite.com"); // hidden copy
$mail->isHTML(true);
$mail->Subject = "Your invoice #1001";
$mail->Body = "<p>Your invoice is attached.</p>";
$mail->AltBody = "Your invoice is attached.";
// Attach a file from disk — give it a clean name the recipient will see.
$mail->addAttachment("/var/invoices/1001.pdf", "invoice-1001.pdf");
try {
$mail->send();
echo "Invoice emailed with attachment.\n";
} catch (Exception $e) {
echo "Failed: {$mail->ErrorInfo}\n";
}
?>Invoice emailed with attachment.4️⃣ Sending to Many — Queue, Don't Block
An SMTP round-trip takes 1–3 seconds. If you send email during a web request, the user waits the whole time — and a newsletter to 1,000 people would time out completely. The fix is a queue: during the request you do a fast database write to record the job, then return immediately. A separate background worker (a script you run on a schedule, like php worker.php) pulls jobs off the queue and does the slow sending out of the user's way.
<?php
// Sending email is SLOW (an SMTP round-trip is ~1–3 seconds). If you do it
// during the web request, the user stares at a spinner. Instead, QUEUE it:
// save the job now (fast), and let a separate worker send it later.
// --- During the web request: just enqueue and return immediately ---
function queueEmail(PDO $db, string $to, string $subject, string $body): void {
$stmt = $db->prepare(
"INSERT INTO email_queue (recipient, subject, body, status)
VALUES (?, ?, ?, 'pending')"
);
$stmt->execute([$to, $subject, $body]); // a quick DB write, not an SMTP call
}
// Sign up 1,000 users to a newsletter without blocking anyone:
$recipients = ["a@example.com", "b@example.com", "c@example.com"]; // ...1000 of them
foreach ($recipients as $email) {
queueEmail($db, $email, "Our newsletter", "<p>Hello!</p>");
}
echo "Queued " . count($recipients) . " emails. Request done.\n";
// --- A separate worker (php worker.php, run on a schedule) does the sending ---
// while ($job = fetchNextPending($db)) {
// sendWithPHPMailer($job); // the slow part runs in the background
// markSent($db, $job['id']);
// }
?>Queued 3 emails. Request done.queueEmail() logic is plain PHP (replace $db with a real PDO connection). Laravel and Symfony give you this queue machinery out of the box.Now you try. The SMTP setup below is almost complete — fill in each ___ using the 👉 hint, then check it against the Output panel.
<?php
// 🎯 YOUR TURN — finish the SMTP setup so the email can authenticate.
require __DIR__ . "/vendor/autoload.php";
use PHPMailer\PHPMailer\PHPMailer;
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = "smtp.sendgrid.net";
// 1) Turn ON SMTP authentication (the server requires a login):
$mail->SMTPAuth = ___; // 👉 use the boolean true
// 2) Read the password from the environment, NEVER hardcode it:
$mail->Password = ___; // 👉 getenv("SMTP_PASSWORD")
// 3) Use the standard submission port for STARTTLS:
$mail->Port = ___; // 👉 the number 587
echo "Auth: " . ($mail->SMTPAuth ? "on" : "off") . ", port " . $mail->Port . "\n";
// ✅ Expected output:
// Auth: on, port 587
?>Auth: on, port 587___ blanks: turn auth on with true, read the password with getenv(...), and set the port to 587.One more. This HTML email needs a plain-text fallback so it isn't flagged as spam. Add the missing AltBody.
<?php
// 🎯 YOUR TURN — give this HTML email a plain-text fallback.
// Some clients (and spam filters) reject HTML-only mail, so ALWAYS set AltBody.
require __DIR__ . "/vendor/autoload.php";
use PHPMailer\PHPMailer\PHPMailer;
$mail = new PHPMailer(true);
$mail->isHTML(true);
$mail->Subject = "Order confirmed";
$mail->Body = "<h1>Order confirmed</h1><p>Thank you!</p>";
// Add a plain-text version of the same message:
$mail->AltBody = ___; // 👉 "Order confirmed. Thank you!"
echo "HTML body set, plain-text fallback: " . $mail->AltBody . "\n";
// ✅ Expected output:
// HTML body set, plain-text fallback: Order confirmed. Thank you!
?>HTML body set, plain-text fallback: Order confirmed. Thank you!___ with a plain-text version of the message, e.g. "Order confirmed. Thank you!"5️⃣ Deliverability: SPF, DKIM & DMARC
Even perfect PHPMailer code lands in spam if your domain doesn't vouch for it. Three DNS TXT records do that. SPF lists which servers may send for your domain. DKIM adds a cryptographic signature proving the message wasn't tampered with. DMARC tells receivers what to do when SPF or DKIM fail (e.g. quarantine), and where to email reports. Set all three and your deliverability jumps dramatically.
DNS records to add (example)
| Record | Host | Value (example) |
|---|---|---|
| SPF | @ | v=spf1 include:sendgrid.net ~all |
| DKIM | s1._domainkey | v=DKIM1; k=rsa; p=MIGfMA0... |
| DMARC | _dmarc | v=DMARC1; p=quarantine; rua=mailto:dmarc@yoursite.com |
In practice you rarely manage all of this yourself. Transactional email providers — SendGrid, Mailgun, Amazon SES, Postmark — maintain trusted sending IPs, sign DKIM for you, and give you analytics, bounce handling, and webhooks. You simply point PHPMailer at their SMTP host (or call their API). For production, this is almost always the right call.
Common Errors (and the fix)
- Your mail lands in the spam folder — you sent it with
mail()or without DNS records. Switch to authenticated SMTP (PHPMailer), add SPF, DKIM, and DMARC, and always include anAltBodyplain-text fallback. - You hardcoded the SMTP password — never put
$mail->Password = "secret123"in source code; it leaks the moment the file is shared or committed. Read it from the environment withgetenv("SMTP_PASSWORD")and keep secrets in a.envfile that's git-ignored. - Your page hangs for several seconds when a user signs up — you're sending email in the request. SMTP is slow. Queue the job (a fast DB write) and let a background worker send it, so the response returns instantly.
- "SMTP connect() failed" with no detail — you didn't pass
new PHPMailer(true), so exceptions are off. Passtrue, wrapsend()intry/catch, and read$mail->ErrorInfo. Missing error handling hides the real cause. - "550 SPF check failed" or messages bounce — your sending server isn't listed in your domain's SPF record (or DKIM/DMARC is missing). Add the DNS
TXTrecords your email provider gives you, then verify with a tool like mail-tester.com.
Pro Tips
- 💡 Use port 587 with STARTTLS for modern submission. Port 465 (implicit TLS) also works; port 25 is for server-to-server and is usually blocked for sending.
- 💡 Always send a multipart message — set both
Body(HTML) andAltBody(plain text). It improves both rendering and spam scores. - 💡 Test deliverability before launch with mail-tester.com — it scores your SPF/DKIM/DMARC and flags spammy content out of 10.
📋 Quick Reference — Email Systems
| Tool / Term | Example | What It Does |
|---|---|---|
| mail() | mail($to, $sub, $body) | Built-in, unauthenticated — avoid |
| PHPMailer | $mail->send() | Authenticated SMTP, the standard way |
| SMTPAuth / Port | true / 587 | Log in over STARTTLS |
| Body / AltBody | HTML / plain text | Send both versions of the message |
| addAttachment() | ("/file.pdf", "name") | Attach a file from disk |
| queue + worker | INSERT … 'pending' | Send in the background, don't block |
| SPF / DKIM / DMARC | DNS TXT records | Prove your mail is legitimate |
| SendGrid / SES | smtp.sendgrid.net | Transactional provider for scale |
Frequently Asked Questions
Q: Why does PHP's mail() function send emails to spam?
mail() hands your message to the server's local sendmail program with no SMTP authentication and no encryption, so receiving servers can't verify that you're allowed to send for your domain. It also returns true as soon as the message is handed off locally — that's not the same as delivery — and gives you no error detail when something goes wrong. For anything real, connect to an authenticated SMTP server with a library like PHPMailer or Symfony Mailer instead.
Q: Should I use PHPMailer or Symfony Mailer?
Both are excellent and do the same core job: authenticated, encrypted SMTP with HTML, attachments, and proper error handling. PHPMailer is a single, mature library you drop into any project — great for plain PHP. Symfony Mailer is the modern choice inside Symfony or Laravel apps (Laravel's Mail facade is built on it) and has a cleaner transport/DSN system. If you're not already in a framework, PHPMailer is the simplest start.
Q: What are SPF, DKIM, and DMARC, and do I really need them?
They're three DNS TXT records that prove your mail is legitimate. SPF lists which servers are allowed to send for your domain. DKIM adds a cryptographic signature so receivers can confirm the message wasn't altered. DMARC tells receiving servers what to do when SPF or DKIM fail (e.g. quarantine or reject) and where to send reports. Without them, Gmail and Outlook will often spam-folder or bounce your mail — so yes, you need all three for reliable delivery.
Q: How do I send thousands of emails without slowing down my site?
Never send email during the web request — each SMTP round-trip takes 1–3 seconds and the user is left waiting. Instead, write the message to a queue (a database table, Redis, or a system like RabbitMQ) in milliseconds and return the response immediately. A separate background worker then pulls jobs off the queue and sends them. This keeps your pages fast and lets you respect provider rate limits.
Q: Why use a service like SendGrid, Mailgun, or Amazon SES instead of my own server?
Running your own mail server means managing IP reputation, deliverability, bounce handling, and blocklists — a full-time job. Transactional providers like SendGrid, Mailgun, and Amazon SES maintain trusted sending IPs, handle SPF/DKIM signing for you, give you delivery analytics and webhooks, and scale to millions of emails. You just point PHPMailer at their SMTP host (or call their API). For production, this is almost always the right choice.
Mini-Challenge: A Reusable sendEmail() Helper
No code is filled in this time — just a brief and an outline. Write the function yourself, then run it on your own server with PHPMailer installed and an SMTP account configured. This is the exact helper you'll reuse across a real application.
<?php
// 🎯 MINI-CHALLENGE: a reusable sendEmail() helper.
// No code is filled in — work from the steps, then run it on your own server.
//
require __DIR__ . "/vendor/autoload.php";
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
// 1. Write a function: sendEmail(string $to, string $subject, string $html): bool
// 2. Inside it, create a PHPMailer(true) and configure SMTP:
// - isSMTP(), Host, SMTPAuth = true, Username
// - Password = getenv("SMTP_PASSWORD") (never hardcode!)
// - SMTPSecure = STARTTLS, Port = 587
// 3. setFrom(), addAddress($to), isHTML(true), Subject, Body, AltBody
// 4. Wrap send() in try/catch:
// - return true on success
// - on Exception, log $mail->ErrorInfo and return false
// 5. Call it once and print "sent" or "failed".
//
// ✅ Expected output (when SMTP is configured correctly):
// sent
// your code here
?>sendEmail($to, $subject, $html) that configures authenticated SMTP, sends HTML + plain text, and returns true/false with a try/catch.🎉 Lesson Complete!
- ✅
mail()is unauthenticated and unreliable — it lands in spam and hides errors - ✅ PHPMailer (or Symfony Mailer) sends over authenticated, TLS-encrypted SMTP
- ✅ Always send HTML
Body+ plain-textAltBody, and read secrets fromgetenv() - ✅ Attach files with
addAttachment(); copy people withaddCC()/addBCC() - ✅ Queue bulk sends and let a background worker do the slow part — never block the request
- ✅ SPF, DKIM, and DMARC prove your mail is legitimate; providers like SendGrid / SES handle scale
- ✅ Next lesson: Image Processing — handle uploads, resize, and optimise images in PHP
Sign up for free to track which lessons you've completed and get learning reminders.