Send Email in PHP: SMTP, HTML, and Attachments
To send email from PHP, use a maintained library such as PHPMailer or Symfony Mailer with an SMTP provider. The library formats the message and attachments; the provider handles sending.
A successful send means the mail server accepted the message. It does not prove the email reached the inbox. You still need to check delivery failures and configure your sending domain.
This guide builds on the request handling and validation covered in our PHP tutorials. Email often follows a form submission, account signup, or completed export.
When Should You Use PHP mail()?
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.
Which PHP Mailer Should You Use?
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.
Whichever library you pick, the pattern is the same:
- 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.
Install PHPMailer with composer require phpmailer/phpmailer. Load Composer's vendor/autoload.php before using its classes, and configure the SMTP environment variables on your server. See the Composer guide for dependency setup.
<?php
use PHPMailer\PHPMailer\PHPMailer;
require __DIR__ . "/vendor/autoload.php";
$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->send(); Use the host, port, and encryption settings supplied by your SMTP provider. The example uses STARTTLS on port 587; do not disable certificate checks to work around connection errors.
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. Both can send mail independently of a framework; choose the library that fits your existing application.
To use Symfony Mailer separately, run composer require symfony/mailer and load vendor/autoload.php. Set MAILER_DSN to the connection string your provider requires. The transport can then change without rewriting the message.
<?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); Keep the decision to send a message separate from the code that connects to the email provider.
Keep Email Setup in One Place
Create a fresh configured mailer through one factory or service for each message, rather than scattering 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. Pass a fresh, configured PHPMailer instance for each message. Reusing an instance can retain recipients, attachments, reply addresses, HTML mode, or alternative text from an earlier email.
Keeping email setup in one place makes an old PHP app easier to maintain. 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.
How Do You Configure SMTP Authentication in PHP?
Configure SMTP authentication in PHP by setting the provider’s host, port, username, password or app password, and encryption mode on your mailer. 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.
Whichever library you choose, configure credentials in one place and decide how to record and retry failed sends.
Treat the From address as a domain decision. 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.
How Do You Attach Files to PHP Emails?
Use the mailer library to attach files to PHP emails. It handles MIME encoding, but your application must still check which file can be sent and how large it is.
Before attaching a file:
- 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.
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.
Why Should PHP Emails Include Plain Text?
PHP emails should include a plain-text alternative so the message remains readable when a client cannot display HTML.
<?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."; PHPMailer creates the multipart message from Body and AltBody. You do not need to write the MIME separators yourself.
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.
Why Might PHP Emails Land in Spam?
PHP emails can land in spam because of domain authentication, sender reputation, or message content. Set up SPF, DKIM, and DMARC with your provider, then monitor bounces and complaints.
These settings belong to your domain and email provider, rather than your PHP script.
DMARC checks whether a domain authenticated by SPF or DKIM aligns with the domain in the visible From address. A message can be valid PHP output and still fail this check.
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.
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 test should show that the app created the right message for the right recipient.
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?
PHP applications should usually send account and customer email through a maintained mailer with authenticated SMTP or a transactional provider. The mail() function can suit a simple internal tool on a server whose mail service you control.
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 should PHP emails include both HTML and plain text?
PHP emails should include a plain-text version so recipients can read the message when HTML is unavailable or blocked. Both versions should contain the same message and links.
Should PHP emails attach large files or link to them?
PHP emails should generally link to large files rather than attach them. For private files, use an expiring link and require authorization when appropriate. This keeps the email small and lets the application control access.
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?
Transactional emails and marketing emails are often better sent through separate subdomains and sending streams. Marketing complaints can affect delivery reputation, so separating the traffic helps protect important messages such as password resets.
Can one email include both an HTML body and file attachments?
An email can contain HTML, a plain-text alternative, and file attachments together. PHPMailer provides isHTML(), AltBody, and addAttachment() to assemble those parts without writing MIME formatting by hand.
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.
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.
A beginner's introduction to PHP: whether it is worth learning in 2026, what runs on it, PHP or Node, whether it is secure, how coding agents change the work, and a roadmap to every PHP guide.