All dispatchesView as Markdown

How to send email in PHP with an API, SMTP, and reliable queues

MC
Maya ChenEmail infrastructure at Email Bump

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

PHP email options
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

send-email.phpphp
<?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

PHP email outcome classes
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

PHP outbox workflowsql
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 idempotently

A 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

Conceptual SQL outbox claimsql
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.