PHP's built-in mail() function is the reason so much PHP email never arrives: it hands the message to a local sendmail binary that usually is not there, and when it is, the message goes out unauthenticated and lands in spam. This guide uses PHPMailer to send through Postwing with authentication, DKIM signing and SPF alignment handled for you.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
composer require phpmailer/phpmailer Construct PHPMailer with true so failures throw instead of returning false — otherwise a broken configuration is indistinguishable from a working one:
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;
require 'vendor/autoload.php';
$mail = new PHPMailer(true); // true = throw exceptions instead of returning false
try {
$mail->isSMTP();
$mail->Host = 'smtp.postwing.app';
$mail->Port = 587;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // 'tls'
$mail->SMTPAuth = true;
$mail->Username = getenv('SMTP_USER');
$mail->Password = getenv('SMTP_PASS');
$mail->Timeout = 10;
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->setFrom('noreply@your-domain.com', 'Acme');
$mail->addAddress('customer@example.com', 'Jane Doe');
$mail->isHTML(true);
$mail->Subject = 'Your order #4417 is confirmed';
$mail->Body = '<h1>Order confirmed</h1><p>Your order ships tomorrow.</p>';
$mail->AltBody = 'Order confirmed. Your order ships tomorrow.'; // always set this
$mail->send();
echo 'Sent';
} catch (Exception $e) {
// $mail->ErrorInfo carries the SMTP-level detail
error_log('Mail failed: ' . $mail->ErrorInfo);
}For implicit TLS on port 465:
// Implicit TLS on port 465 instead of STARTTLS
$mail->Port = 465;
$mail->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; // 'ssl'SMTPOptions block with verify_peer => false that circulates on forums switches TLS validation off entirely, so the connection can be silently intercepted. If verification fails, fix the CA bundle (openssl.cafile) rather than bypassing the check. $mail->addAttachment('/srv/invoices/2026-03.pdf', 'invoice.pdf');
// From a string you generated in memory
$mail->addStringAttachment($pdfData, 'invoice.pdf', 'base64', 'application/pdf');
// Inline image, referenced from the HTML as <img src="cid:logo">
$mail->addEmbeddedImage('/var/www/assets/logo.png', 'logo', 'logo.png'); By default PHPMailer connects, authenticates and disconnects for every message. SMTPKeepAlive holds the connection open — just remember to clear the recipients between sends, or each message goes to everyone who came before:
// Reuse one authenticated connection for a batch of messages.
$mail->SMTPKeepAlive = true;
foreach ($users as $user) {
$mail->clearAddresses(); // otherwise recipients accumulate
$mail->addAddress($user->email);
$mail->Subject = 'Your weekly report';
$mail->Body = renderReport($user);
try {
$mail->send();
} catch (Exception $e) {
error_log("Failed for {$user->email}: {$mail->ErrorInfo}");
}
}
$mail->smtpClose(); Never put the visitor's address in setFrom(). Your domain's DKIM and SPF cannot vouch for someone else's address, so the message fails DMARC and is discarded. Use addReplyTo():
// Contact form: the From stays on YOUR domain, the visitor goes in Reply-To.
$mail->setFrom('noreply@your-domain.com', 'Website');
$mail->addAddress('sales@your-domain.com');
$mail->addReplyTo($_POST['email'], $_POST['name']);
$mail->Subject = 'New contact form submission';
$mail->Body = nl2br(htmlspecialchars($_POST['message'], ENT_QUOTES, 'UTF-8'));
$mail->AltBody = $_POST['message'];// Temporarily print the whole SMTP conversation while debugging.
$mail->SMTPDebug = SMTP::DEBUG_SERVER;
$mail->Debugoutput = 'error_log';| Error | Cause and fix |
|---|---|
SMTP connect() failed | Blocked outbound port — use 8587 or 8465 — or encryption not matching the port. |
SMTP Error: Could not authenticate | Wrong credentials, or SMTPAuth left false. |
Could not connect to SMTP host + SSL error | ENCRYPTION_SMTPS on 587, or ENCRYPTION_STARTTLS on 465. |
| Accented characters are mangled | Set $mail->CharSet = PHPMailer::CHARSET_UTF8. |
| Each message goes to more people than intended | Missing clearAddresses() inside a send loop. |
SMTP Error: data not accepted (550) | The setFrom domain is not verified, or the token does not cover it. |
mail() hands the message to a local sendmail binary. Most modern hosting has no local mail server, so the call returns true and the message is discarded. Even when one exists the mail is unauthenticated, so it fails SPF and DKIM checks at the recipient and is filtered as spam. An authenticated SMTP client such as PHPMailer avoids both problems.
PHPMailer::ENCRYPTION_STARTTLS with port 587, or PHPMailer::ENCRYPTION_SMTPS with port 465. The string values 'tls' and 'ssl' mean the same thing. Mismatching the constant and the port is the most common cause of a failed handshake.
No. The allow_self_signed and verify_peer => false snippet found in many tutorials turns off TLS certificate validation entirely, which makes the connection interceptable. The real cause is usually a missing or outdated CA bundle — point openssl.cafile at a current one instead.
Both are actively maintained. PHPMailer is the smaller dependency and drops into legacy code with no framework. Symfony Mailer has a cleaner API, DSN-based configuration and asynchronous transports, so it fits better in a modern application — Laravel is built on it.
Either the host blocks outbound ports 25, 465 and 587 — switch to 8587 or 8465 — or the encryption setting does not match the port. Enable SMTPDebug = SMTP::DEBUG_SERVER to see exactly where the conversation stops.
Yes, whenever Body is HTML. A message with only an HTML part is a recognised spam signal and renders as nothing in clients that prefer plain text. AltBody adds the text alternative that makes the message multipart/alternative.