Send email from PHP with a production-ready REST example, safe secrets, validation, timeouts, idempotency, queues, SMTP alternatives, attachments, and delivery webhooks.
PHP can send email through its mail() function, an authenticated SMTP library, or an HTTP email API. For a modern web application, an API is usually the clearest integration boundary: the request and response are structured, credentials stay server-side, and provider message IDs and webhooks make outcomes traceable. SMTP remains useful for frameworks and existing mailer abstractions.
The difficult part is not issuing a request. Production email must survive process crashes, database rollbacks, provider timeouts, duplicate jobs, stale account state, bounces, and complaints without losing important messages or sending them repeatedly.
Choose the PHP email method
METHOD GOOD FIT MAIN TRADEOFF
Email API Modern apps needing structured results Provider HTTP contract
SMTP library Existing framework/mailer integration Protocol errors and connection state
PHP mail() Host-managed local mail submission Weak app-level evidence and portability
Local MTA Teams operating their own mail stack Security, reputation, queue operations
Campaign API Newsletters and audience sends Different consent/content state model
PHP mail() returning true means the message was accepted for delivery by the local
mail system; PHP's manual explicitly says that does not mean it reached its destination.Send one email through a REST API
<?php
declare(strict_types=1);
function requiredEnv(string $name): string {
$value = trim((string) getenv($name));
if ($value === '') {
throw new RuntimeException("Missing environment variable: {$name}");
}
return $value;
}
function sendEmail(array $message): array {
$json = json_encode($message, JSON_THROW_ON_ERROR);
$handle = curl_init('https://emailbump.com/api/v1/emails');
if ($handle === false) {
throw new RuntimeException('Could not initialize cURL');
}
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $json,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . requiredEnv('EMAIL_BUMP_API_KEY'),
'Content-Type: application/json',
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT_MS => 3_000,
CURLOPT_TIMEOUT_MS => 10_000,
]);
$body = curl_exec($handle);
$status = curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$error = curl_error($handle);
curl_close($handle);
if ($body === false || $error !== '') {
throw new RuntimeException('Email request ended without a usable response');
}
if ($status < 200 || $status >= 300) {
throw new RuntimeException("Email API returned HTTP {$status}");
}
return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
$result = sendEmail([
'from' => requiredEnv('EMAIL_FROM'),
'to' => '[email protected]',
'subject' => 'Your receipt',
'html' => '<h1>Payment received</h1><p>Thanks for your order.</p>',
'text' => "Payment received\n\nThanks for your order.",
]);Enable PHP's cURL extension in the runtime, keep the API key outside source control, and configure the verified sender in server-owned settings. The example demonstrates transport mechanics; important application mail should normally be submitted by a durable worker after business state commits.
Classify API failures before retrying
OUTCOME CLASS ACTION
2xx + provider message ID accepted store ID; await delivery events
400/401/403/422 permanent fix payload, auth, sender, or policy
408/429 transient back off; honor provider guidance
5xx with definite rejection transient bounded retry from durable job
connect timeout unknown safe to retry under intent idempotency
read timeout after POST ambiguous provider may have accepted; reconcile
JSON parse/malformed response ambiguous preserve request ID; do not retry blindly
Accepted is not delivered. A later recipient-server response can still defer or bounce.The simple function throws one exception type to stay readable. Production code should preserve HTTP status, provider request ID, response category, retry guidance, and whether a response was received. Never log the Authorization header, complete message body, reset URL, or sensitive attachment while diagnosing failure.
Validate the business command
- Authenticate and authorize the caller before resolving any recipient.
- Accept a purpose such as send_receipt or invite_member—not arbitrary From, To, Subject, and HTML from the browser.
- Load the recipient and current account state from trusted storage.
- Choose an allowlisted template and verified sender for the message purpose.
- Validate and escape variables for HTML, text, header, and URL contexts separately.
- Rate-limit public triggers such as contact, reset, invite, and verification forms.
- Create a stable operation or intent ID before a remote request can succeed ambiguously.
Use a transactional outbox for important email
DATABASE TRANSACTION
update order/account/invoice state
insert email_outbox(intent_key UNIQUE, purpose, entity_id, payload_version)
COMMIT
|
v
QUEUE WORKER
claim row with lease -> re-read authoritative state -> render -> submit API
|
v
MESSAGE LEDGER
store provider ID / ambiguity / attempt -> process signed webhooks idempotentlyA request handler that updates an order and then calls the provider can lose the email if PHP exits between those operations. Calling the provider before commit can send a receipt for a transaction that later rolls back. The transactional outbox records business state and email intent together, then lets a worker own remote retries.
A minimal outbox claim pattern
WITH candidate AS (
SELECT id
FROM email_outbox
WHERE status = 'pending' AND available_at <= CURRENT_TIMESTAMP
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE email_outbox
SET status = 'claimed', claimed_at = CURRENT_TIMESTAMP, attempts = attempts + 1
WHERE id = (SELECT id FROM candidate)
RETURNING *;Use leases or a recovery rule so a crashed worker does not strand the row forever. Make the intent key unique to the business transition, not merely the queue attempt. Re-read current state before sending delayed work so a canceled invitation or already-paid invoice does not produce stale mail.
SMTP from PHP
Use a maintained SMTP library or framework mailer rather than hand-building SMTP commands. Configure port 587 with required STARTTLS or port 465 with implicit TLS according to the provider; validate certificates, set timeouts, and use server-side credentials. PHPMailer and Symfony Mailer are common abstractions, but the surrounding queue and state rules remain yours.
Avoid treating PHP mail() as proof of delivery. Its official manual states that a true result only means the local mail system accepted the message for delivery. Applications still need a durable intent, a trackable provider or MTA message ID, bounce processing, and operational visibility.
Send HTML and plain text
- Render one semantic source into compatible HTML and a meaningful text alternative.
- Escape untrusted values; do not concatenate user input directly into HTML or headers.
- Use absolute HTTPS URLs built from trusted application configuration.
- Keep secrets, passwords, full card details, private exports, and powerful long-lived tokens out of email.
- Test missing variables, long names, Unicode, images blocked, dark mode, zoom, and screen-reader navigation.
- Keep critical transactional content separate from optional promotional modules.
Handle attachments carefully
Read attachments from controlled server storage, enforce a small application limit before base64 encoding, and never accept an arbitrary filesystem path or URL from request input. Verify allowed type using content evidence rather than filename alone, use a safe filename, and prefer authenticated download links for large or sensitive files.
Process delivery webhooks
- Verify the provider signature against the exact raw request body before decoding trusted fields.
- Deduplicate event IDs and allow delivery events to arrive more than once or out of order.
- Map provider message IDs back to your immutable intent and safe business reference.
- Suppress permanent invalid recipients and complaints without interpreting sender-policy failures as invalid addresses.
- Expose delivery evidence to support without exposing message secrets or unnecessary personal data.
- Return success quickly and process downstream work asynchronously so the provider does not retry needlessly.
PHP production checklist
- Keep provider credentials in environment or secret storage and rotate them independently.
- Resolve sender, recipient, template, and authorization on the server.
- Set connect and total request timeouts and classify ambiguous outcomes.
- Persist important email intent in the same transaction as business state.
- Run remote submission in a durable worker with bounded, idempotent retries.
- Store provider IDs and verify signed delivery webhooks.
- Test duplicate jobs, rollbacks, process termination, stale state, 429, 5xx, timeout, bounce, and complaint.
- Authenticate the sending domain with SPF, DKIM, and DMARC and monitor the actual production route.
Frequently asked questions
What is the best way to send email in PHP?
For many web applications, use a server-side email API or a maintained SMTP library behind a provider-neutral adapter. Choose API when structured errors and HTTP operations fit your stack; choose SMTP when a framework abstraction already owns message construction. Reliability still requires durable intent and delivery events.
Why not use PHP mail()?
It can hand a message to a configured local mail system, but behavior depends on host configuration and a successful return does not prove destination delivery. An authenticated provider API or SMTP service normally provides clearer submission, domain verification, events, and operational evidence.
Can PHP send HTML email through an API?
Yes. Send both HTML and plain-text representations in the provider request, with a verified sender and server-resolved recipient. Escape dynamic content, use absolute links, and test the rendered MIME output and delivery events.