For anything people depend on, like password resets and receipts, don't use mail(). It hands the message to the server's local mail program with no login and no DKIM signature, so it often lands in spam, and many hosts block or limit it. Use PHPMailer with an SMTP provider on port 587, or call an email API over HTTPS.
All three ways are below, with working code, how to test without emailing real people, and the errors you'll most likely hit.
Three ways to send, compared
mail() |
PHPMailer + SMTP | HTTP email API | |
|---|---|---|---|
| Setup | Nothing to install | composer require phpmailer/phpmailer |
An SDK, or cURL |
| Signed with DKIM | Only if the server is set up for it | Yes, by your provider | Yes, by your provider |
| Delivery status | None | Accepted or rejected by the provider | Message ID, status and webhooks |
| Ports | 25, often blocked | 587 or 2525 | HTTPS only (443) |
| Use it for | Local testing | Existing SMTP code and apps | New code, and hosts that block mail ports |
Option 1: PHP's built-in mail()
It's one function call, which is why every tutorial starts here:
<?php
$to = 'ada@example.com';
$subject = 'Your receipt';
$message = 'Thanks for your order.';
$headers = [
'From' => 'Acme <noreply@yourdomain.com>',
'Content-Type' => 'text/plain; charset=UTF-8',
];
$sent = mail($to, $subject, $message, $headers);
mail() returning true only means the server's local mail program accepted the message. It doesn't mean anyone received it. In production that gap shows up as:
- Spam folders. The message leaves from the web server's IP, unsigned. Gmail and Outlook expect SPF, DKIM and DMARC that line up with your From address.
- Silent drops. Many shared hosts and most cloud providers block outbound port 25, so the mail never leaves the machine.
- No feedback. No message ID, no bounce, no delivery status. You find out when a user says the reset never came.
- Header injection. Putting user input into
$headerslets an attacker add their own recipients. Never do it.
Option 2: PHPMailer over SMTP
PHPMailer is the standard library for SMTP in PHP. It logs in to your email provider's server, upgrades the connection with STARTTLS, and builds a proper MIME message with HTML, a plain-text part and attachments.
composer require phpmailer/phpmailer
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require 'vendor/autoload.php';
$mail = new PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = 'smtp.groundmail.dev';
$mail->SMTPAuth = true;
$mail->Username = 'groundmail';
$mail->Password = getenv('GROUNDMAIL_SMTP_PASSWORD');
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->setFrom('noreply@yourdomain.com', 'Acme');
$mail->addAddress('ada@example.com');
$mail->isHTML(true);
$mail->Subject = 'Your receipt';
$mail->Body = '<p>Thanks for your order.</p>';
$mail->AltBody = 'Thanks for your order.';
$mail->addAttachment('/path/to/receipt.pdf');
$mail->send();
} catch (Exception $e) {
error_log("Email failed: {$mail->ErrorInfo}");
}
The host, port, username and password come from your provider. With Groundmail they're smtp.groundmail.dev, port 587 (or 2525 if your host blocks 587), username groundmail, and an SMTP credential as the password.
Option 3: an HTTP email API
An API skips SMTP entirely: one HTTPS request per email, which works on hosts that block every mail port, and you get a message ID and structured errors back. With the Groundmail PHP SDK:
composer require groundmail/groundmail
<?php
use Groundmail\Client;
$tx = new Client(getenv('GROUNDMAIL_API_KEY'));
$result = $tx->emails->send([
'from' => 'Acme <noreply@yourdomain.com>',
'to' => 'ada@example.com',
'subject' => 'Your receipt',
'html' => '<p>Thanks for your order.</p>',
'text' => 'Thanks for your order.',
]);
echo $result['id'], ' ', $result['status'], PHP_EOL;
No Composer? The same call with PHP's cURL extension:
<?php
$ch = curl_init('https://api.groundmail.dev/emails');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('GROUNDMAIL_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'from' => 'Acme <noreply@yourdomain.com>',
'to' => 'ada@example.com',
'subject' => 'Your receipt',
'html' => '<p>Thanks for your order.</p>',
]),
]);
$response = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
On Laravel or Symfony, use the framework's mailer with our Laravel package or Symfony Mailer transport instead, so queues and mailables keep working.
Test it without emailing real people
Whichever way you send, you need to see the email before your users do, and your tests need to read the code or link inside it. There are two parts to that: a local inbox while you build, and a test inbox for CI and staging.
While you build: Groundhog
Groundhog is a free, open-source local inbox that catches everything your app sends. Nothing leaves your laptop. Start it, point PHPMailer at port 1025, and open localhost:3025 to see each email in desktop, mobile, light and dark, with the one-time code and links pulled out.
npx @groundmail/groundhog
# or: docker run --rm -p 1025:1025 -p 3025:3025 ghcr.io/groundmail/groundhog
// PHPMailer, in development
$mail->Host = 'localhost';
$mail->Port = 1025;
$mail->SMTPAuth = false;
$mail->SMTPAutoTLS = false;
// Or the Groundmail SDK: any key works locally
$tx = new Client('gm_test_anything', 'http://localhost:3025');
It also takes SendGrid, Postmark, Mailgun, Resend and SES API calls at the same address, and can make the next sends fail with a 429 or a timeout so you can test your error handling.
In CI and staging: the test inbox
Your Groundmail team gets a test domain. Mail sent to any address there is caught and never delivered, so a PHPUnit test can trigger a real password reset and read the link back, whatever your app sends with. See how email testing works.
public function testPasswordResetEmailHasALink(): void
{
$to = 'reset-' . bin2hex(random_bytes(4)) . '@acme.test.groundmail.dev';
// Trigger your app's real password reset for $to here.
$this->requestPasswordReset($to);
$ch = curl_init('https://api.groundmail.dev/test-inbox/wait?'
. http_build_query(['to' => $to, 'timeout' => 30]));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('GROUNDMAIL_API_KEY')],
]);
$email = json_decode(curl_exec($ch), true);
$this->assertStringContainsString('/reset-password', $email['links'][0]);
}
Replace acme with your team's test domain, shown on the Test inbox page in the dashboard.
Common errors and what they mean
mail() returns true but nothing arrives. The local mail program accepted it; the trouble is after that. Check the server's mail log and the recipient's spam folder, then move to SMTP or an API so you get a real answer.
SMTP Error: Could not authenticate. Wrong username or password, or the password has a character your .env file mangles. With Groundmail the username is always groundmail and the password is an SMTP credential, not your API key.
SMTP connect() failed. The host blocks outbound mail ports, or the host or port is wrong. Try port 2525, or switch to the HTTP API, which only needs HTTPS.
550 5.7.26 This message does not pass authentication checks. Gmail refused it because SPF and DKIM don't line up with your From domain. Send from a domain you've verified with your provider. More in 550 5.7.26.
Sender address rejected or domain is not verified. The From address isn't on a domain your provider has verified. Use an address on your verified sending domain.
Before you ship
- Send from your own domain with SPF, DKIM and DMARC set up, not from a free mailbox address.
- Don't send inside the web request. Put the email on a queue so a slow mail server never slows a page load.
- Retry safely. Send an idempotency key with API calls so a retry after a timeout can't send the same receipt twice.
- Always include a plain-text part.
AltBodyin PHPMailer,textin the API. - Keep secrets out of the code. Read the API key or SMTP password from the environment.
Send from PHP with Groundmail
The SDK and SMTP relay above work today. Groundmail sends transactional, product and opt-in newsletter email from separate IPs on our own mail servers, with delivery status for every message. The free plan covers 3,000 emails a month: start sending free.
Frequently asked questions
Is there an API to send emails?
Yes. Email APIs take one HTTPS request per message, so you don't need an SMTP library, and they return a message ID you can track. In PHP you can call one with an SDK or with the cURL extension.
Is SMTP for sending emails?
Yes. SMTP is the protocol mail servers use to send and relay email. PHPMailer speaks it to your provider's server, which then delivers the message to the recipient's mail server.
How do I send the same email to a list of addresses?
Send one email per recipient in a loop, or use your provider's batch endpoint, rather than putting everyone in To or BCC. Each person gets their own message and their own delivery status.
Why does mail() work locally but not on my server?
Your machine may relay through a configured mail program, while the server has none set up or its host blocks port 25. SMTP to a provider, or an HTTPS API, behaves the same everywhere.