Building Email Systems
Reviewed & published by Brayan K
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.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
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.
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.
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("[email protected]", "Billing");
$mail->addAddress("[email protected]");
$mail->addCC("[email protected]"); // visible copy
$mail->addBCC("[email protected]"); // 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";
}
?>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
}
// Your app hands you the connection; an in-memory SQLite one makes this
// snippet runnable exactly as it stands.
$db = new PDO("sqlite::memory:");
$db->exec("CREATE TABLE email_queue (id INTEGER PRIMARY KEY, recipient TEXT,
subject TEXT, body TEXT, status TEXT)");
// Sign up 1,000 users to a newsletter without blocking anyone:
$recipients = ["[email protected]", "[email protected]", "[email protected]"]; // ...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']);
// }
?>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
?>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!
?>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:[email protected] |
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 an AltBody plain-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 with getenv("SMTP_PASSWORD") and keep secrets in a .env file 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. Pass true, wrap send() in try/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 TXT records 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) and AltBody (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 |
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
?>🎉 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-text AltBody, and read secrets from getenv()
- ✅ Attach files with addAttachment(); copy people with addCC() / 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
Practice quiz
Why is PHP's built-in mail() function unreliable for real applications?
- It can only send plain-text emails, never HTML
- It is deprecated and removed in PHP 8.4
- It hands the message to the local mail program with no SMTP authentication or encryption, so receivers distrust it and it often lands in spam
- It always throws an exception you must catch
Answer: It hands the message to the local mail program with no SMTP authentication or encryption, so receivers distrust it and it often lands in spam. mail() has no auth and no encryption, and returns true once the message is merely handed off locally — not when it is actually delivered.
In the PHPMailer setup, which port is used for STARTTLS submission?
- 587
- 25
- 465
- 8080
Answer: 587. Port 587 is the submission port used with STARTTLS; 465 is implicit TLS and 25 is server-to-server (usually blocked for sending).
Where should the SMTP password come from in the PHPMailer examples?
- Hardcoded directly in the source file
- From a public URL fetched at runtime
- From the email subject line
- From the environment, e.g. getenv("SMTP_PASSWORD")
Answer: From the environment, e.g. getenv("SMTP_PASSWORD"). Secrets are read from the environment with getenv() and never written into the code, where they would leak the moment the file is shared or committed.
What is the purpose of PHPMailer's AltBody property?
- It sets the email's subject line
- It provides a plain-text fallback for clients that block HTML, which also improves spam scores
- It attaches a file to the message
- It hides the recipient from other recipients
Answer: It provides a plain-text fallback for clients that block HTML, which also improves spam scores. Always set both Body (HTML) and AltBody (plain text) — HTML-only mail is more likely to be flagged as spam.
Which PHPMailer method adds a HIDDEN copy that other recipients cannot see?
- addBCC()
- addCC()
- addAddress()
- addAttachment()
Answer: addBCC(). addBCC() adds a hidden copy; addCC() adds a visible one.
Why should bulk email sends be queued instead of sent during the web request?
- Because mail providers ban sending from web requests
- Because queued emails skip spam filters
- Because an SMTP round-trip takes 1–3 seconds, so sending in-request makes the user wait and can time out
- Because PHPMailer cannot run inside a request
Answer: Because an SMTP round-trip takes 1–3 seconds, so sending in-request makes the user wait and can time out. Queue the job with a fast DB write and let a background worker do the slow SMTP sending, so the response returns instantly.
What does the SPF DNS record do for email deliverability?
- It encrypts the message body
- It lists which servers are allowed to send mail for your domain
- It adds a cryptographic signature to each message
- It tells receivers what to do when checks fail
Answer: It lists which servers are allowed to send mail for your domain. SPF lists permitted sending servers; DKIM signs the message; DMARC says what to do when SPF or DKIM fail.
What does the DKIM record add to an outgoing email?
- A list of allowed sending IP addresses
- A queue priority level
- The plain-text fallback body
- A cryptographic signature proving the message wasn't altered
Answer: A cryptographic signature proving the message wasn't altered. DKIM adds a cryptographic signature so receivers can confirm the message wasn't tampered with in transit.
Passing new PHPMailer(true) does what?
- Enables HTML mode automatically
- Turns on exceptions so failures can be caught with try/catch and read from $mail->ErrorInfo
- Sends the email immediately
- Disables TLS encryption
Answer: Turns on exceptions so failures can be caught with try/catch and read from $mail->ErrorInfo. The true argument enables exceptions; without it you get 'SMTP connect() failed' with no detail.
Why use a transactional provider like SendGrid, Mailgun, or Amazon SES?
- They let you skip writing any PHP code
- They make mail() reliable again
- They maintain trusted sending IPs, handle SPF/DKIM signing, and scale to millions of emails
- They are the only way to send attachments
Answer: They maintain trusted sending IPs, handle SPF/DKIM signing, and scale to millions of emails. Providers handle IP reputation, DKIM signing, analytics, and scale — you just point PHPMailer at their SMTP host.
Continue this course
- Previous: Working with Queues: Redis, Beanstalk, RabbitMQ
- Next: Image Uploads, Processing & Optimisation (GD & Imagick) — Handle user image uploads, resize, crop, and optimise with GD and Imagick
- Quick reference: PHP cheat sheet
Frequently asked questions
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.
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.
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.
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.
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.