Sending Email in PHP
For real application email, use a maintained mailer library or a framework mailer. Building headers by hand, attaching a file with a MIME boundary, calling mail(), and hoping the message arrives is the pattern to leave behind.
Think of PHP as the desk that hands a parcel to a mailroom. A successful handoff means the mailroom accepted it, not that the recipient opened it. Email is an integration with authentication, DNS, spam filtering, MIME formatting, retries, and provider policy, so treat it like infrastructure instead of a string-building exercise.
This is the email step in the PHP tutorial track. Read it after you understand request handling and validation, because mail is rarely a feature by itself. It is usually the last side effect of a form, export, comment, account flow, or background job.
What Mail() Is Good For
mail() can send a basic message when the server is already configured with a working mail transfer agent.
<?php
$headers = [
"From" => "CodeWalkers <hello@example.com>",
"Reply-To" => "hello@example.com",
"Content-Type" => "text/plain; charset=UTF-8",
];
mail(
"reader@example.com",
"Your account is ready",
"You can now sign in.",
$headers,
); The array form of the additional headers parameter requires PHP 7.2 or newer. Older PHP versions expect the headers as a string, but new code should not recreate that manual formatting on an obsolete runtime.
That is fine for understanding the underlying primitive. It is not enough for SMTP authentication, HTML plus text alternatives, attachments, inline images, DKIM, reliable errors, queueing, or provider-specific delivery behavior.
The security warning is also plain: if outside data is used in headers, sanitize it so users cannot inject extra headers. The safest version is to avoid hand-building user-controlled headers at all.
mail() still has a place in a small internal tool on a server you administer yourself. It can also help explain the primitive underneath the abstraction. But it is the wrong boundary for an account system, password reset flow, customer export, invoice, notification feed, or anything that has to be observable when delivery fails.
Mailer Libraries
PHPMailer remains a practical standalone option because it handles SMTP authentication, attachments, multipart messages, UTF-8, and header-injection protection. Symfony Mailer is a strong fit when you are already inside Symfony or using its components.
The mailer is the application's mailroom desk. The current pattern is broader than one preferred library:
- Compose the message through a mailer.
- Send through authenticated SMTP or a transactional email provider.
- Keep credentials in environment configuration.
- Log provider errors.
- Test locally with a catcher instead of sending real messages.
<?php
use PHPMailer\PHPMailer\PHPMailer;
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = $_ENV["SMTP_HOST"];
$mail->SMTPAuth = true;
$mail->Username = $_ENV["SMTP_USER"];
$mail->Password = $_ENV["SMTP_PASSWORD"];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->setFrom("hello@example.com", "CodeWalkers");
$mail->addAddress("reader@example.com");
$mail->Subject = "Your export is ready";
$mail->Body = "Download your export from the dashboard.";
$mail->addAttachment($pathToExport);
$mail->send(); That is more code than mail(), but the extra lines are the point. Those extra lines make the mail transport explicit.
PHPMailer is still the simple standalone choice when the application is plain PHP, a small legacy app, or a framework that does not already own email. Symfony Mailer is usually a better fit when the app already has a container, environment-driven configuration, and a queue layer. The two libraries are solving the same boundary from different starting points: one begins as a mail-sending class you can drop into a project, the other begins as part of an application framework.
Symfony's docs make the boundary explicit with a transport DSN in configuration. That matters because the code that composes the message should not know whether today's transport is SMTP, a local catcher, or a provider bridge.
<?php
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
$transport = Transport::fromDsn($_ENV["MAILER_DSN"]);
$mailer = new Mailer($transport);
$email = (new Email())
->from("hello@example.com")
->to("reader@example.com")
->subject("Your export is ready")
->text("Download your export from the dashboard.")
->html("<p>Download your export from the dashboard.</p>");
$mailer->send($email); That example is deliberately boring, which is a good sign for email code. The interesting work should be in the product decision that triggers the message, not in a hand-built MIME string.
Keep the Mail Boundary Small
Do not scatter mailer setup through controllers, form handlers, and cron scripts. Put the transport setup in one factory or service, then keep each message as a small named operation. The code that approves a comment should not know about SMTP ports. The code that sends the message should not decide whether the comment was valid.
<?php
function sendExportReadyEmail(PHPMailer $mail, string $recipient, string $downloadUrl): void
{
$mail->clearAllRecipients();
$mail->clearAttachments();
$mail->addAddress($recipient);
$mail->Subject = "Your export is ready";
$mail->Body = "Download it here: {$downloadUrl}";
$mail->send();
} That shape is easier to test, easier to replace with a provider API later, and easier to rate-limit. It also keeps sensitive SMTP configuration away from ordinary request code. Reset every recipient and attachment before composing each reused message so an earlier CC, BCC, or file cannot leak into the next send.
This boundary has a large effect on whether an old PHP app remains maintainable. A single MailerFactory plus named message functions provides one place for logging, a fake transport, a local development catcher, and provider-specific retry behavior. A dozen scattered mail() calls creates a search project every time the SMTP password changes.
Do not log the full message body by default. Log the message type, recipient domain, provider message id when you have one, success or failure, and enough context to find the related row. For password resets, invoices, exports, and account notifications, the logs should tell you what happened without leaking secrets or personal data into the log store.
Authentication Belongs to the Provider
Authenticated SMTP usually means host, port, username, password or app password, encryption mode, and provider-specific limits. Some providers also support OAuth-based SMTP, and some teams skip SMTP entirely in favor of a transactional email API, with Postmark, Amazon SES, and Resend as neutral examples. The PHP code should hide that choice behind configuration.
The important part is not whether the first version uses PHPMailer, Symfony Mailer, or a framework mailer. The important part is that the app has one mail boundary, clear credentials, logged failures, and a plan for retries. A form handler that calls mail() directly has none of that.
Treat the From address as a domain decision, not a convenience field. If the message claims to come from example.com, the sending provider, DNS records, and bounce handling all need to line up with that domain. Use Reply-To for replies, and use the provider's envelope sender or return-path setup for delivery and bounce processing instead of improvising headers in PHP.
Attachments Need MIME Discipline
Legacy attachment scripts often treated MIME as a small string trick. The failure mode is wider than a broken attachment. A bad MIME boundary, wrong content type, unsafe filename, or unbounded file size can create deliverability problems or security problems.
Before attaching files, check these constraints deliberately:
- Generate or validate the filename you expose to the recipient.
- Check file size before loading it into memory.
- Store private files outside the web root where possible.
- Use a mailer API for MIME encoding work.
- Avoid attaching user-uploaded files unless the workflow really needs it.
For many applications, the better design is a signed download link with an expiry time. The email stays small, and the app keeps control over access.
That pattern matters for any hand-built attachment code. The goal was never "build MIME by hand forever." The goal was "send the recipient the file they asked for," and a signed link is often the cleaner implementation of it.
If you do attach a file, let the mailer read and encode it. PHPMailer's addAttachment() and Symfony's attachment APIs exist so application code does not have to assemble multipart boundaries by hand. Check the file path before attaching it, use a safe display name, and keep user-controlled filenames out of the headers unless they have been normalized.
Text Fallback
If you send HTML, send a plain-text alternative. Some mail clients prefer it, some security tools inspect it, and it gives the message a better fallback path.
<?php
$mail->isHTML(true);
$mail->Subject = "Reset your password";
$mail->Body = "<p>Use this link to reset your password.</p>";
$mail->AltBody = "Use this link to reset your password."; Building that multipart structure by hand meant chasing a different mail client every few weeks as spam filters tightened their MIME parsing. Let the library do the awkward encoding work.
Keep transactional templates small at first, especially when the message carries an account or security action. A transactional message needs a clear subject, a recognizable sender, the one action the recipient should take, and enough plain text to make sense in a security product or terminal mail client. CSS-heavy newsletter habits are usually the wrong model for a password reset or export notification.
Deliverability Is Outside PHP
PHP can hand the parcel to an SMTP server. It cannot make the receiving provider trust your domain. For a production domain, set up SPF, DKIM, and DMARC with the email provider, then monitor bounces and complaints.
That is not PHP work, but it decides whether the PHP feature actually works.
The 2026 DMARC standard still centers the same operational idea: receivers compare authenticated identifiers from SPF or DKIM with the visible author domain, then apply the domain owner's published policy when validation fails. That is a DNS and provider setup problem, not a line of PHP. If the domain is wrong, the PHP code can be perfectly written and the message can still land in spam.
For a small application, start with one verified sending domain and one provider. Avoid sending product mail from a personal inbox, a random server hostname, or a domain that has no authentication records. Those shortcuts work just long enough to make debugging miserable later.
Avoid Accidental Bulk Mail
Once email works, the next temptation is a loop. A CSV import, admin screen, or database query can turn one message into thousands. That needs a queue or batch process, not a browser request that tries to send everything before the page times out.
At minimum, separate the selection of recipients from the sending step:
- Validate the input that decides who receives mail.
- Store a pending message or job.
- Send in controlled batches.
- Record success and failure per recipient.
- Retry only failures that are safe to retry.
That is the difference between "the PHP script can send email" and "the product can operate email without surprising everyone."
Local development deserves the same caution as production. Use a mail catcher, fake transport, or test provider mode so a form test does not send real messages to users. The first proof should show that the app created the right message for the right recipient, not that a developer's laptop can reach the public SMTP provider.
Common Pitfalls & Debugging
Mail() Returns True but the Message Never Arrives
Symptom: PHP reports success, but the inbox stays empty. Cause: mail() reports that the local system accepted the message, not that a receiving server delivered it. Fix: inspect the transport or provider event, then check domain authentication, bounces, suppression lists, and spam placement.
User Input Reaches a Mail Header
Symptom: a recipient, subject, or reply address creates extra headers or malformed mail. Cause: untrusted text was concatenated into a header. Fix: validate addresses, reject line breaks in header values, and pass values through a maintained mailer's methods.
One Action Sends the Message Twice
Symptom: a retry or double form submission sends duplicate account mail. Cause: the request sends before recording a unique message job. Fix: create an idempotent job or outbox record first, then let one worker own each send attempt.
Frequently Asked Questions
Should PHP use mail() or authenticated SMTP?
Use mail() only for a simple tool on a server with a mail transfer agent you control. Production account and customer email should use a maintained mailer with authenticated SMTP or a transactional provider.
Can PHP mailers send attachments?
Maintained PHP mailers can send attachments through dedicated APIs. PHPMailer, Symfony Mailer, and framework mailers handle the MIME encoding. Validate the path, size, media type, and display name before attaching a file, especially when any value came from a user.
Why send both HTML and plain text?
The plain-text part gives mail clients and security tools a readable fallback when HTML is unavailable or blocked. Keep both versions semantically equivalent so the recipient sees the same action and message in either format.
Should large files be attached or linked?
Use an expiring signed download link for large or private files. The message stays small, the application keeps control over authorization, and the recipient can retry the download without receiving another copy of the attachment.
What is the actual difference between SPF and DKIM?
SPF is a DNS record listing which mail servers are allowed to send for a domain, checked when a message arrives. DKIM adds a cryptographic signature to outgoing mail so the receiver can verify the message was not altered after it was signed.
Should transactional email like password resets share the same sending domain as marketing email?
Usually not. Using the same mailer library is fine, but a marketing send's spam complaints and bounce rate can damage the reputation of a shared sending domain, which then affects whether a critical password reset email reaches the inbox at all.
Can one email include both an HTML body and file attachments?
Yes. MIME supports combining a multipart HTML-and-text body with one or more attachments in the same message, which is exactly what a mailer library's isHTML and addAttachment methods are built to assemble without hand-built boundaries.
Self-Check
Predict the output from this transport result helper:
<?php
function acceptanceLabel(bool $accepted): string
{
return $accepted ? "accepted" : "rejected";
}
echo acceptanceLabel(true); - Predict the output: Which word does the snippet print?
- Multiple choice: Does that word prove inbox delivery: yes or no?
- Multiple choice: Where should SMTP credentials live: in the message function, browser code, or environment configuration?
- Multiple choice: Which is safer for a large private export: a direct attachment or an expiring signed link?
- Predict the output: Which word would print if the helper received
falseinstead?
Answers
accepted. The helper receivestrueand selects the first branch of the conditional expression.- No. Acceptance describes the handoff to the mail system, not the final delivery result.
- Environment configuration. The transport boundary reads credentials without committing or exposing them in ordinary application code.
- An expiring signed link. It keeps the message small and lets the application enforce access when the recipient downloads the file.
rejected. Afalseargument selects the second branch of the conditional expression.
Next Steps
If the email is triggered by an API endpoint or database action, read build your own API with PHP and PHP security fundamentals next. Email is usually the last step in a request that already needs validation, rate limits, logging, and careful error handling.
For the broader PHP tutorial track, return to PHP tutorials.
This example has been updated from the original CodeWalkers email tutorials, which built MIME structures by hand and treated a true return from mail() as proof of delivery. The questions they asked still stand: how to send email, authenticate SMTP, include attachments, and keep the script understandable.
Sources
-
[1]
PHP mail(php.net)
-
[2]
PHPMailer(github.com)
-
[3]
Symfony Mailer(symfony.com)
- [4]
Read Next
Validate email-address shape in PHP with filter_var, then separate syntax checks from confirmation, deliverability, and account ownership.
Secure ordinary PHP surfaces with current password hashing, sessions, CSRF tokens, output escaping, prepared statements, and upload controls.
The map of the PHP section: what PHP actually is, where the language came from, and the topic cards in this section.