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

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)

RecordHostValue (example)
SPF@v=spf1 include:sendgrid.net ~all
DKIMs1._domainkeyv=DKIM1; k=rsa; p=MIGfMA0...
DMARC_dmarcv=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)

Pro Tips

📋 Quick Reference — Email Systems

Tool / TermExampleWhat It Does
mail()mail($to, $sub, $body)Built-in, unauthenticated — avoid
PHPMailer$mail->send()Authenticated SMTP, the standard way
SMTPAuth / Porttrue / 587Log in over STARTTLS
Body / AltBodyHTML / plain textSend both versions of the message
addAttachment()("/file.pdf", "name")Attach a file from disk
queue + workerINSERT … 'pending'Send in the background, don't block
SPF / DKIM / DMARCDNS TXT recordsProve your mail is legitimate
SendGrid / SESsmtp.sendgrid.netTransactional 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!

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

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.

Related lessons