Sending Email
Almost every web app sends email: order confirmations, password reset links, contact form messages, OTPs and receipts. PHP has a built-in mail() function, so sending email looks like a one-line job. In practice, mail sent the simple way often never arrives or lands in the spam folder.
In this lesson you will see why mail() is unreliable, send email properly over SMTP with PHPMailer or Symfony Mailer, learn the key security rule (no user input in headers), meet SPF and DKIM, and test email safely on your own computer.
How email is delivered
An email travels through SMTP (Simple Mail Transfer Protocol) servers. Your app hands the message to an SMTP server, which passes it to the receiver's provider (Gmail, Outlook, a company server). The receiver then decides: inbox, spam or reject, based on signals such as "Is this server allowed to send for this domain?".
An email has headers (lines like From:, To:, Subject: and Reply-To:) and a body. Headers are separated by line breaks, which is exactly what makes them dangerous with user input.
The built-in mail() function
mail() takes the recipient, subject, message and optional headers. Since PHP 7.2 you can pass the headers as an array:
<?php
declare(strict_types=1);
$to = 'priya@example.com';
$subject = 'Your library card is ready';
$message = "Hi Priya,\r\nYour card for Kochi Public Library is ready for pickup.";
$headers = [
'From' => 'library@example.in',
'Reply-To' => 'help@example.in',
'Content-Type' => 'text/plain; charset=UTF-8',
];
$accepted = mail($to, $subject, $message, $headers);
var_dump($accepted);bool(true)
That true does not mean Priya received the email. It only means the local mail system accepted it. Here is why mail() is a poor choice for real apps:
- It depends on server setup. On Linux it hands the message to a local program such as sendmail; on Windows it uses the
SMTPandsmtp_portsettings inphp.ini. Often nothing is set up, so mail silently goes nowhere. - No login. It cannot sign in to an SMTP server with a user name and password.
- Poor delivery. Mail from a web server's own IP address is usually not covered by your domain's SPF and DKIM records, so providers distrust it.
- Little error information. You mostly get
falseand no reason. PHP 8.5 helps slightly: with sendmail,mail()now reports the real sendmail error as a warning. - Manual work. HTML plus plain-text versions, attachments and non-English subjects all need hand encoding.
You will still see mail() in older code, often with headers joined into one string with "\r\n". For new projects, use SMTP through a library.
Never put user input in headers
Suppose a contact form puts the visitor's email address into a Reply-To: header. An attacker types a line break into the field, followed by a new header of their choice:
<?php
declare(strict_types=1);
// What an attacker types into the "Your email" box
$fromForm = "rohan@example.com\r\nBcc: victim1@example.net, victim2@example.net";
// Unsafe: the line break starts a brand-new header
echo "Reply-To: $fromForm\n\n";
var_dump(filter_var($fromForm, FILTER_VALIDATE_EMAIL));
function headerSafe(string $value): bool
{
return !preg_match('/[\r\n]/', $value);
}
var_dump(headerSafe('Rohan Verma'));
var_dump(headerSafe("Rohan\r\nBcc: victim@example.net"));Reply-To: rohan@example.com Bcc: victim1@example.net, victim2@example.net bool(false) bool(true) bool(false)
The first two lines of output show the attack: one header has become two, and the hidden Bcc: header would send your message to any addresses the attacker chooses. Your server becomes a spam machine, and your domain gets blacklisted. This is called email header injection.
Good mail libraries validate addresses and encode headers for you, which blocks this attack, but validating input yourself is still the rule. The Filters lesson covers FILTER_VALIDATE_EMAIL in detail.
SMTP with PHPMailer
PHPMailer is the most widely used PHP mail library. It speaks SMTP, logs in to your mail provider, encrypts the connection, builds HTML and plain-text versions, and handles attachments. Install it with Composer (see Namespaces & Composer):
composer require phpmailer/phpmailerYou need SMTP details from your mail provider or hosting panel: a host name, a port, a user name and a password. Keep them in a config file outside the web root, never in the code itself:
<?php
declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require __DIR__ . '/vendor/autoload.php';
$config = require __DIR__ . '/config/mail.php'; // host, port, username, password
$mail = new PHPMailer(true); // true: throw exceptions on errors
try {
$mail->isSMTP();
$mail->Host = $config['host'];
$mail->SMTPAuth = true;
$mail->Username = $config['username'];
$mail->Password = $config['password'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $config['port']; // usually 587 for STARTTLS
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom('orders@example.in', 'Meera Sweets');
$mail->addAddress('rohan@example.com', 'Rohan Verma');
$mail->addReplyTo('help@example.in', 'Meera Sweets Help');
$mail->Subject = 'Your order ORD-1042 is confirmed';
$mail->isHTML(true);
$mail->Body = '<p>Thank you, Rohan! Your order of <strong>₹1,299</strong> will reach Pune in 2 days.</p>';
$mail->AltBody = 'Thank you, Rohan! Your order of ₹1,299 will reach Pune in 2 days.';
$mail->send();
echo "Confirmation sent to Rohan\n";
} catch (Exception $e) {
error_log('Mail error: ' . $mail->ErrorInfo);
echo "Sorry, we could not send the email right now.\n";
}Confirmation sent to Rohan
new PHPMailer(true) makes every problem throw an exception, so failures cannot pass silently. ENCRYPTION_STARTTLS with port 587 encrypts the connection; some providers use ENCRYPTION_SMTPS with port 465 instead. AltBody is the plain-text version for mail apps that do not show HTML. The real error goes to your log, while the visitor sees a polite message.
To attach a file, call $mail->addAttachment('/path/to/invoice.pdf'); before send(). Escape any user-supplied text in an HTML body with htmlspecialchars(), exactly as on a web page.
The alternative: Symfony Mailer
Symfony Mailer is the other popular choice. Laravel uses it for its own mail features. Install it with composer require symfony/mailer. The connection is described by one DSN string (transport, user, password, host and port), which fits nicely in an environment variable:
<?php
declare(strict_types=1);
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
require __DIR__ . '/vendor/autoload.php';
// For example: smtp://user%40example.in:password@smtp.example.in:587
$mailer = new Mailer(Transport::fromDsn(getenv('MAILER_DSN')));
$email = new Email()
->from(new Address('orders@example.in', 'Meera Sweets'))
->to(new Address('isha@example.com', 'Isha Kapoor'))
->replyTo('help@example.in')
->subject('Your Diwali hamper is on its way')
->text('Hi Isha, your hamper (₹2,499) left our Jaipur shop today.')
->html('<p>Hi Isha, your hamper (<strong>₹2,499</strong>) left our Jaipur shop today.</p>');
try {
$mailer->send($email);
echo "Sent to Isha\n";
} catch (TransportExceptionInterface $e) {
error_log('Mail error: ' . $e->getMessage());
echo "Could not send the email.\n";
}Sent to Isha
The Email object is built with a chain of methods, and new Email()->from(...) without extra brackets needs PHP 8.4. Special characters in the user name or password must be URL-encoded inside the DSN, which is why @ appears as %40. Both libraries are good choices; pick one per project and stick with it.
SPF, DKIM and DMARC in brief
Even perfect code cannot make Gmail trust your messages. Trust comes from three DNS records on your domain:
| Record | What it says |
|---|---|
| SPF | Which servers are allowed to send email for this domain |
| DKIM | A public key; your mail server signs every message with the matching private key, so receivers can check it was not changed |
| DMARC | What receivers should do when SPF or DKIM checks fail, and where to send reports |
Large providers now expect these records, especially from sites that send a lot of mail. Your mail provider tells you exactly which SPF and DKIM records to add for your domain, which is the main reason SMTP through a real provider delivers better than mail().
Testing email safely
During development, never send real emails to real customers by accident. Run a local mail catcher such as Mailpit: it acts as an SMTP server on your computer (port 1025 by default), keeps every message, and shows them in a web page (port 8025). Point your config at it while developing:
<?php
// config/mail.php on your own computer only
return [
'host' => '127.0.0.1',
'port' => 1025,
'username' => '',
'password' => '',
];With PHPMailer, also set SMTPAuth to false and SMTPSecure to an empty string for Mailpit, since it needs no login or encryption. With Symfony Mailer, the DSN is simply smtp://127.0.0.1:1025.
Advanced: do not make visitors wait
Talking to an SMTP server can take seconds, and a slow provider should not break your checkout page. Larger apps therefore queue emails: the page saves the message in a database table and returns at once, and a script run by cron (the server's scheduler) sends waiting messages every minute, retrying failures. Laravel has queues built in. For a small site, sending inside a try block, as above, is fine.
Common mistakes
- Trusting
mail()returningtrue. It only means the local system accepted the message, not that it was delivered. - Putting the visitor's address in
From. Send from your own domain and useReply-Tofor the visitor. - Allowing line breaks in header values. That opens the door to header injection. Validate and reject
\rand\n. - Hard-coding SMTP passwords in code. Keep them in a config file outside the web root or in an environment variable.
- Testing with real addresses. Use Mailpit or another catcher so customers never get test messages.
Key points
mail()depends on server setup, cannot log in to SMTP and gives little error information; use it only in old code.- Send through SMTP with PHPMailer (
composer require phpmailer/phpmailer) or Symfony Mailer (composer require symfony/mailer). - Use STARTTLS on port 587 (or SMTPS on 465), send
Fromyour own domain, and include a plain-text version. - Never put raw user input in headers: validate emails and reject line breaks to stop header injection.
- SPF, DKIM and DMARC DNS records decide whether providers trust your mail.
- Catch test emails locally with a tool such as Mailpit.
Exercise
Write a function sendContactMessage(array $post, PHPMailer $mail): string for a contact form with name, email and message fields. It should return an error message if the name is empty or contains a line break, if the email is invalid, or if the message is shorter than 10 characters. Otherwise it should send the message to help@example.in from website@example.in, with the visitor as Reply-To, and return 'Thank you, we will reply soon.'. Assume $mail is already configured for SMTP.
Show solution
<?php
declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
function sendContactMessage(array $post, PHPMailer $mail): string
{
$name = trim((string) ($post['name'] ?? ''));
$email = filter_var(trim((string) ($post['email'] ?? '')), FILTER_VALIDATE_EMAIL);
$message = trim((string) ($post['message'] ?? ''));
if ($name === '' || preg_match('/[\r\n]/', $name)) {
return 'Please enter your name on one line.';
}
if ($email === false) {
return 'Please enter a valid email address.';
}
if (mb_strlen($message) < 10) {
return 'Your message must be at least 10 characters.';
}
try {
$mail->setFrom('website@example.in', 'Website contact form');
$mail->addAddress('help@example.in');
$mail->addReplyTo($email, $name);
$mail->Subject = 'New message from ' . $name;
$mail->Body = $message;
$mail->send();
} catch (Exception $e) {
error_log('Contact form mail failed: ' . $mail->ErrorInfo);
return 'Sorry, something went wrong. Please try again later.';
}
return 'Thank you, we will reply soon.';
}The visitor's address goes only into Reply-To, and only after validation. The name is checked for line breaks because it also appears in a header (the subject and the reply-to name).
Quick Quiz
1. What does it mean when mail() returns true?
true only means PHP handed the message over; delivery can still fail later.
2. Why is a line break in a form field dangerous for email?
Headers are separated by line breaks, so an attacker can add headers; this is header injection.
3. Where should a contact form visitor's email address go?
Send From your own domain, so SPF and DKIM pass, and use Reply-To for the visitor.
4. Which record lists the servers allowed to send email for your domain?
SPF lists allowed senders; DKIM adds a signature that receivers can check.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
