How to Send Emails in PHP (2026 Guide)

Most "how to send email in PHP" tutorials start with mail(). Don't use mail(). It has no authentication, no TLS, no error handling, and most hosting providers disable it. Even when it works, your emails go straight to spam.
This guide covers how to send emails from PHP properly: using API-based providers with cURL or Guzzle, building HTML templates, handling errors, processing Stripe webhooks, and scaling with queues. If you're working with a different language, check out our guides for Node.js or Python. All examples use PHP 8.1+ and work with vanilla PHP or any framework.
Pick an Email Provider
You have three solid options. Each code example below lets you switch between them.
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. It also has built-in retries and native Stripe integration.
- Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.
Why Not mail() or PHPMailer?
// mail() — don't use in production
mail("user@example.com", "Hello", "Body text");
// No auth, no TLS, no bounce handling, goes to spam
// PHPMailer — fine for SMTP, but lots of config
$mail = new PHPMailer(true);
$mail->isSMTP();
$mail->Host = 'smtp.gmail.com';
$mail->SMTPAuth = true;
$mail->Username = 'you@gmail.com';
$mail->Password = 'app-specific-password';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// ... 15 more lines before you can send
// API provider — one HTTP call
$response = $client->post('/v1/transactional/send', [
'json' => ['to' => 'user@example.com', 'subject' => 'Hello', 'body' => '<p>Hi</p>']
]);
// Clean, reliable, handles deliverability for youUse mail() never. Use PHPMailer only if you need a specific SMTP server. Use an API provider for everything else.
Install Dependencies
# No SDK needed — just use cURL (built-in) or Guzzle
composer require guzzlehttp/guzzlecomposer require resend/resend-phpcomposer require sendgrid/sendgridAdd your API key to a .env file. Use vlucas/phpdotenv to load it:
composer require vlucas/phpdotenvSEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereLoad environment variables early in your app:
<?php
// bootstrap.php or index.php
require_once __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();Create the Email Client
Build a clean, reusable email class. PHP 8.1+ has constructor promotion, enums, and named arguments that make this elegant.
<?php
namespace App\Email;
use RuntimeException;
class EmailClient
{
private const BASE_URL = 'https://api.sequenzy.com/v1';
public function __construct(
private readonly string $apiKey
) {}
public static function fromEnv(): self
{
$key = $_ENV['SEQUENZY_API_KEY'] ?? getenv('SEQUENZY_API_KEY');
if (!$key) {
throw new RuntimeException('SEQUENZY_API_KEY not set');
}
return new self($key);
}
public function send(string $to, string $subject, string $body): array
{
$ch = curl_init(self::BASE_URL . '/transactional/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'to' => $to,
'subject' => $subject,
'body' => $body,
]),
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new NetworkException("cURL error: {$error}");
}
$data = json_decode($response, true);
if ($httpCode >= 400) {
throw EmailException::fromResponse($httpCode, $data);
}
return $data;
}
}<?php
namespace App\Email;
use Resend;
use RuntimeException;
class EmailClient
{
private Resend\Client $client;
private string $from;
public function __construct(string $apiKey, string $from = 'Your App <noreply@yourdomain.com>')
{
$this->client = Resend::client($apiKey);
$this->from = $from;
}
public static function fromEnv(): self
{
$key = $_ENV['RESEND_API_KEY'] ?? getenv('RESEND_API_KEY');
if (!$key) {
throw new RuntimeException('RESEND_API_KEY not set');
}
return new self($key);
}
public function send(string $to, string $subject, string $html): array
{
try {
$result = $this->client->emails->send([
'from' => $this->from,
'to' => $to,
'subject' => $subject,
'html' => $html,
]);
return ['id' => $result->id];
} catch (\Exception $e) {
throw new EmailException(
"Email send failed: {$e->getMessage()}",
$e->getCode(),
$e
);
}
}
}<?php
namespace App\Email;
use SendGrid;
use SendGrid\Mail\Mail;
use RuntimeException;
class EmailClient
{
private SendGrid $client;
private string $from;
public function __construct(string $apiKey, string $from = 'noreply@yourdomain.com')
{
$this->client = new SendGrid($apiKey);
$this->from = $from;
}
public static function fromEnv(): self
{
$key = $_ENV['SENDGRID_API_KEY'] ?? getenv('SENDGRID_API_KEY');
if (!$key) {
throw new RuntimeException('SENDGRID_API_KEY not set');
}
return new self($key);
}
public function send(string $to, string $subject, string $html): array
{
$email = new Mail();
$email->setFrom($this->from);
$email->setSubject($subject);
$email->addTo($to);
$email->addContent('text/html', $html);
$response = $this->client->send($email);
if ($response->statusCode() >= 400) {
throw EmailException::fromResponse(
$response->statusCode(),
json_decode($response->body(), true)
);
}
return ['status' => $response->statusCode()];
}
}Custom Exception Classes
Build structured exceptions so you can handle different failure modes:
<?php
// src/Email/EmailException.php
namespace App\Email;
use RuntimeException;
class EmailException extends RuntimeException
{
public function __construct(
string $message,
private readonly int $statusCode = 0,
private readonly bool $retryable = false,
?\Throwable $previous = null,
) {
parent::__construct($message, $statusCode, $previous);
}
public static function fromResponse(int $statusCode, ?array $body): self
{
$message = $body['error'] ?? $body['message'] ?? 'Unknown error';
return match (true) {
$statusCode === 429 => new self("Rate limited: {$message}", $statusCode, retryable: true),
$statusCode === 401 => new self("Invalid API key: {$message}", $statusCode, retryable: false),
$statusCode >= 500 => new self("Server error: {$message}", $statusCode, retryable: true),
default => new self("Email send failed ({$statusCode}): {$message}", $statusCode),
};
}
public function isRetryable(): bool
{
return $this->retryable;
}
public function getStatusCode(): int
{
return $this->statusCode;
}
}
// src/Email/NetworkException.php
class NetworkException extends EmailException
{
public function __construct(string $message, ?\Throwable $previous = null)
{
parent::__construct($message, 0, retryable: true, previous: $previous);
}
}Send Your First Email
The simplest possible email. No template, just getting something out the door.
<?php
require_once __DIR__ . '/vendor/autoload.php';
use App\Email\EmailClient;
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$client = EmailClient::fromEnv();
$result = $client->send(
'user@example.com',
'Hello from PHP',
'<p>Your app is sending emails. Nice.</p>'
);
echo 'Sent! Job ID: ' . $result['jobId'] . PHP_EOL;<?php
require_once __DIR__ . '/vendor/autoload.php';
use App\Email\EmailClient;
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$client = EmailClient::fromEnv();
$result = $client->send(
'user@example.com',
'Hello from PHP',
'<p>Your app is sending emails. Nice.</p>'
);
echo 'Sent! ID: ' . $result['id'] . PHP_EOL;<?php
require_once __DIR__ . '/vendor/autoload.php';
use App\Email\EmailClient;
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$client = EmailClient::fromEnv();
$result = $client->send(
'user@example.com',
'Hello from PHP',
'<p>Your app is sending emails. Nice.</p>'
);
echo 'Sent! Status: ' . $result['status'] . PHP_EOL;php send.phpBuild Email Templates
Raw HTML strings get messy fast. Use PHP's built-in templating or Twig for clean, reusable email templates.
Option 1: PHP Templates (No Dependencies)
PHP itself is a template engine. Use .phtml files with a simple renderer:
<?php
// src/Email/TemplateRenderer.php
namespace App\Email;
class TemplateRenderer
{
public function __construct(
private readonly string $templateDir,
private readonly string $layoutFile = 'layout.phtml',
) {}
public function render(string $template, array $data = []): string
{
// Render inner template
$inner = $this->renderFile(
$this->templateDir . '/' . $template . '.phtml',
$data
);
// Wrap in layout
return $this->renderFile(
$this->templateDir . '/' . $this->layoutFile,
array_merge($data, ['content' => $inner])
);
}
private function renderFile(string $file, array $data): string
{
extract($data, EXTR_SKIP);
ob_start();
include $file;
return ob_get_clean();
}
}<!-- templates/layout.phtml -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
<div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
<?= $content ?>
</div>
<div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px;">
<p>© <?= date('Y') ?> Your App. All rights reserved.</p>
</div>
</body>
</html><!-- templates/welcome.phtml -->
<h1 style="font-size:24px;margin-bottom:16px;">
Welcome, <?= htmlspecialchars($name) ?>
</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your account is ready. Click below to get started.
</p>
<a href="<?= htmlspecialchars($loginUrl) ?>"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
margin-top:16px;">
Go to Dashboard
</a>Option 2: Twig Templates
If you want template inheritance, auto-escaping, and a cleaner syntax, use Twig:
composer require twig/twig<?php
// src/Email/TwigRenderer.php
namespace App\Email;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
class TwigRenderer
{
private Environment $twig;
public function __construct(string $templateDir)
{
$loader = new FilesystemLoader($templateDir);
$this->twig = new Environment($loader, [
'autoescape' => 'html',
]);
}
public function render(string $template, array $data = []): string
{
return $this->twig->render($template . '.html.twig', $data);
}
}{# templates/layout.html.twig #}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body style="margin:0;padding:0;background-color:#f6f9fc;font-family:sans-serif;">
<div style="max-width:480px;margin:40px auto;background:#fff;border-radius:8px;padding:40px;">
{% block content %}{% endblock %}
</div>
<div style="text-align:center;padding:20px;color:#9ca3af;font-size:12px;">
<p>© {{ "now"|date("Y") }} Your App. All rights reserved.</p>
</div>
</body>
</html>{# templates/welcome.html.twig #}
{% extends "layout.html.twig" %}
{% block content %}
<h1 style="font-size:24px;margin-bottom:16px;">Welcome, {{ name }}</h1>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Your account is ready. Click below to get started.
</p>
<a href="{{ login_url }}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
margin-top:16px;">
Go to Dashboard
</a>
{% endblock %}Send Templated Emails
<?php
namespace App\Email;
class Emails
{
public function __construct(
private readonly EmailClient $client,
private readonly TemplateRenderer $renderer,
) {}
public function welcome(string $email, string $name): array
{
$html = $this->renderer->render('welcome', [
'name' => $name,
'loginUrl' => 'https://app.yoursite.com/dashboard',
]);
return $this->client->send($email, "Welcome, {$name}", $html);
}
public function passwordReset(string $email, string $token): array
{
$html = $this->renderer->render('password_reset', [
'resetUrl' => "https://app.yoursite.com/reset-password?token={$token}",
]);
return $this->client->send($email, 'Reset your password', $html);
}
public function paymentReceipt(
string $email,
int $amount,
string $plan,
string $invoiceUrl
): array {
$formatted = '$' . number_format($amount / 100, 2);
$html = $this->renderer->render('receipt', [
'formatted' => $formatted,
'plan' => $plan,
'invoiceUrl' => $invoiceUrl,
]);
return $this->client->send($email, "Payment receipt - {$formatted}", $html);
}
}<?php
namespace App\Email;
class Emails
{
public function __construct(
private readonly EmailClient $client,
private readonly TemplateRenderer $renderer,
) {}
public function welcome(string $email, string $name): array
{
$html = $this->renderer->render('welcome', [
'name' => $name,
'loginUrl' => 'https://app.yoursite.com/dashboard',
]);
return $this->client->send($email, "Welcome, {$name}", $html);
}
public function passwordReset(string $email, string $token): array
{
$html = $this->renderer->render('password_reset', [
'resetUrl' => "https://app.yoursite.com/reset-password?token={$token}",
]);
return $this->client->send($email, 'Reset your password', $html);
}
public function paymentReceipt(
string $email,
int $amount,
string $plan,
string $invoiceUrl
): array {
$formatted = '$' . number_format($amount / 100, 2);
$html = $this->renderer->render('receipt', [
'formatted' => $formatted,
'plan' => $plan,
'invoiceUrl' => $invoiceUrl,
]);
return $this->client->send($email, "Payment receipt - {$formatted}", $html);
}
}<?php
namespace App\Email;
class Emails
{
public function __construct(
private readonly EmailClient $client,
private readonly TemplateRenderer $renderer,
) {}
public function welcome(string $email, string $name): array
{
$html = $this->renderer->render('welcome', [
'name' => $name,
'loginUrl' => 'https://app.yoursite.com/dashboard',
]);
return $this->client->send($email, "Welcome, {$name}", $html);
}
public function passwordReset(string $email, string $token): array
{
$html = $this->renderer->render('password_reset', [
'resetUrl' => "https://app.yoursite.com/reset-password?token={$token}",
]);
return $this->client->send($email, 'Reset your password', $html);
}
public function paymentReceipt(
string $email,
int $amount,
string $plan,
string $invoiceUrl
): array {
$formatted = '$' . number_format($amount / 100, 2);
$html = $this->renderer->render('receipt', [
'formatted' => $formatted,
'plan' => $plan,
'invoiceUrl' => $invoiceUrl,
]);
return $this->client->send($email, "Payment receipt - {$formatted}", $html);
}
}Send from a Web Endpoint
Whether you use Slim, vanilla PHP, or any other framework, the email sending pattern is the same:
<?php
// public/api/send-welcome.php
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Email\{EmailClient, Emails, TemplateRenderer};
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../../');
$dotenv->load();
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$data = json_decode(file_get_contents('php://input'), true);
$to = filter_var($data['email'] ?? '', FILTER_VALIDATE_EMAIL);
$name = trim($data['name'] ?? '');
if (!$to || !$name) {
http_response_code(400);
echo json_encode(['error' => 'Valid email and name are required']);
exit;
}
try {
$client = EmailClient::fromEnv();
$renderer = new TemplateRenderer(__DIR__ . '/../../templates');
$emails = new Emails($client, $renderer);
$result = $emails->welcome($to, $name);
echo json_encode($result);
} catch (\App\Email\EmailException $e) {
error_log("Email send failed: {$e->getMessage()}");
http_response_code(500);
echo json_encode(['error' => 'Failed to send email']);
}Test it:
php -S localhost:8000 -t public
curl -X POST http://localhost:8000/api/send-welcome.php \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "name": "Jane"}'Common Email Patterns for SaaS
Password Reset
<!-- templates/password_reset.phtml -->
<h2 style="font-size:20px;">Password Reset</h2>
<p style="font-size:16px;line-height:1.6;color:#374151;">
Click the link below to reset your password. This link expires in 1 hour.
</p>
<a href="<?= htmlspecialchars($resetUrl) ?>"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;
border-radius:6px;text-decoration:none;font-size:14px;font-weight:600;
margin-top:16px;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>Payment Receipt
<!-- templates/receipt.phtml -->
<h2 style="font-size:20px;">Payment Received</h2>
<p style="font-size:16px;color:#374151;">Thanks for your payment. Here's your receipt:</p>
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
<tr>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
<td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">
<?= htmlspecialchars($plan) ?>
</td>
</tr>
<tr>
<td style="padding:8px;font-weight:600;">Total</td>
<td style="padding:8px;text-align:right;font-weight:600;">
<?= htmlspecialchars($formatted) ?>
</td>
</tr>
</table>
<a href="<?= htmlspecialchars($invoiceUrl) ?>" style="color:#f97316;">View full invoice</a>Stripe Webhook Handler
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Email\{EmailClient, Emails, TemplateRenderer};
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../../');
$dotenv->load();
// Read raw body for signature verification
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$webhookSecret = $_ENV['STRIPE_WEBHOOK_SECRET'];
// Verify Stripe signature
$elements = [];
foreach (explode(',', $sigHeader) as $part) {
[$key, $value] = explode('=', $part, 2);
$elements[$key] = $value;
}
$timestamp = $elements['t'] ?? '';
$signature = $elements['v1'] ?? '';
$signedPayload = "{$timestamp}.{$payload}";
$expected = hash_hmac('sha256', $signedPayload, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
// Process event
$event = json_decode($payload, true);
$client = EmailClient::fromEnv();
$renderer = new TemplateRenderer(__DIR__ . '/../../templates');
$emails = new Emails($client, $renderer);
match ($event['type']) {
'checkout.session.completed' => (function () use ($event, $emails) {
$session = $event['data']['object'];
$emails->paymentReceipt(
$session['customer_email'],
$session['amount_total'],
'Pro',
$session['invoice'] ?? '#'
);
})(),
'invoice.payment_failed' => (function () use ($event, $client) {
$invoice = $event['data']['object'];
$client->send(
$invoice['customer_email'],
'Payment failed',
'<h1>Payment issue</h1><p>We couldn\'t process your latest payment. Please update your card.</p>'
);
})(),
default => null,
};
header('Content-Type: application/json');
echo json_encode(['received' => true]);<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Email\{EmailClient, Emails, TemplateRenderer};
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../../');
$dotenv->load();
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$webhookSecret = $_ENV['STRIPE_WEBHOOK_SECRET'];
// Verify Stripe signature
$elements = [];
foreach (explode(',', $sigHeader) as $part) {
[$key, $value] = explode('=', $part, 2);
$elements[$key] = $value;
}
$timestamp = $elements['t'] ?? '';
$signature = $elements['v1'] ?? '';
$signedPayload = "{$timestamp}.{$payload}";
$expected = hash_hmac('sha256', $signedPayload, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$event = json_decode($payload, true);
$client = EmailClient::fromEnv();
$renderer = new TemplateRenderer(__DIR__ . '/../../templates');
$emails = new Emails($client, $renderer);
match ($event['type']) {
'checkout.session.completed' => (function () use ($event, $emails) {
$session = $event['data']['object'];
$emails->paymentReceipt(
$session['customer_email'],
$session['amount_total'],
'Pro',
$session['invoice'] ?? '#'
);
})(),
'invoice.payment_failed' => (function () use ($event, $client) {
$invoice = $event['data']['object'];
$client->send(
$invoice['customer_email'],
'Payment failed',
'<h1>Payment issue</h1><p>We couldn\'t process your latest payment. Please update your card.</p>'
);
})(),
default => null,
};
header('Content-Type: application/json');
echo json_encode(['received' => true]);<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use App\Email\{EmailClient, Emails, TemplateRenderer};
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/../../');
$dotenv->load();
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$webhookSecret = $_ENV['STRIPE_WEBHOOK_SECRET'];
// Verify Stripe signature
$elements = [];
foreach (explode(',', $sigHeader) as $part) {
[$key, $value] = explode('=', $part, 2);
$elements[$key] = $value;
}
$timestamp = $elements['t'] ?? '';
$signature = $elements['v1'] ?? '';
$signedPayload = "{$timestamp}.{$payload}";
$expected = hash_hmac('sha256', $signedPayload, $webhookSecret);
if (!hash_equals($expected, $signature)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$event = json_decode($payload, true);
$client = EmailClient::fromEnv();
$renderer = new TemplateRenderer(__DIR__ . '/../../templates');
$emails = new Emails($client, $renderer);
match ($event['type']) {
'checkout.session.completed' => (function () use ($event, $emails) {
$session = $event['data']['object'];
$emails->paymentReceipt(
$session['customer_email'],
$session['amount_total'],
'Pro',
$session['invoice'] ?? '#'
);
})(),
'invoice.payment_failed' => (function () use ($event, $client) {
$invoice = $event['data']['object'];
$client->send(
$invoice['customer_email'],
'Payment failed',
'<h1>Payment issue</h1><p>We couldn\'t process your latest payment. Please update your card.</p>'
);
})(),
default => null,
};
header('Content-Type: application/json');
echo json_encode(['received' => true]);Error Handling with Retries
Build a retry wrapper that respects the retryable flag from your exception:
<?php
// src/Email/RetryableEmailClient.php
namespace App\Email;
class RetryableEmailClient
{
public function __construct(
private readonly EmailClient $client,
private readonly int $maxRetries = 3,
) {}
public function send(string $to, string $subject, string $body): array
{
$lastException = null;
for ($attempt = 0; $attempt <= $this->maxRetries; $attempt++) {
try {
return $this->client->send($to, $subject, $body);
} catch (EmailException $e) {
$lastException = $e;
error_log(sprintf(
'Email attempt %d/%d failed for %s: %s',
$attempt + 1,
$this->maxRetries + 1,
$to,
$e->getMessage()
));
// Don't retry non-retryable errors (bad API key, validation)
if (!$e->isRetryable()) {
throw $e;
}
if ($attempt < $this->maxRetries) {
$delay = (int) pow(2, $attempt);
sleep($delay); // 1s, 2s, 4s
}
}
}
throw $lastException;
}
}
// Usage
$client = new RetryableEmailClient(EmailClient::fromEnv(), maxRetries: 3);
$client->send('user@example.com', 'Subject', '<p>Body</p>');Background Sending with Queues
Email sends should not block your web request. PHP has several options for background processing.
Option 1: Database Queue (No Dependencies)
A simple job queue using your existing database:
<?php
// src/Queue/EmailQueue.php
namespace App\Queue;
use PDO;
class EmailQueue
{
public function __construct(private readonly PDO $db) {}
public function push(string $to, string $subject, string $body, ?string $scheduledAt = null): int
{
$stmt = $this->db->prepare(
'INSERT INTO email_queue (recipient, subject, body, scheduled_at, status, created_at)
VALUES (?, ?, ?, ?, "pending", NOW())'
);
$stmt->execute([$to, $subject, $body, $scheduledAt]);
return (int) $this->db->lastInsertId();
}
public function process(int $batchSize = 10): int
{
// Claim a batch of pending jobs
$this->db->exec(
"UPDATE email_queue
SET status = 'processing', updated_at = NOW()
WHERE status = 'pending'
AND (scheduled_at IS NULL OR scheduled_at <= NOW())
ORDER BY created_at ASC
LIMIT {$batchSize}"
);
$stmt = $this->db->query(
"SELECT * FROM email_queue WHERE status = 'processing'"
);
$processed = 0;
while ($job = $stmt->fetch(PDO::FETCH_ASSOC)) {
try {
$this->sendEmail($job);
$this->markComplete($job['id']);
$processed++;
} catch (\Exception $e) {
$this->markFailed($job['id'], $e->getMessage(), (int) $job['attempts']);
}
}
return $processed;
}
private function markComplete(int $id): void
{
$stmt = $this->db->prepare(
"UPDATE email_queue SET status = 'sent', updated_at = NOW() WHERE id = ?"
);
$stmt->execute([$id]);
}
private function markFailed(int $id, string $error, int $attempts): void
{
$status = $attempts >= 3 ? 'failed' : 'pending';
$stmt = $this->db->prepare(
"UPDATE email_queue SET status = ?, error = ?, attempts = attempts + 1, updated_at = NOW() WHERE id = ?"
);
$stmt->execute([$status, $error, $id]);
}
}-- Migration
CREATE TABLE email_queue (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
recipient VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
status ENUM('pending', 'processing', 'sent', 'failed') DEFAULT 'pending',
scheduled_at TIMESTAMP NULL,
attempts TINYINT UNSIGNED DEFAULT 0,
error TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL,
INDEX idx_status_scheduled (status, scheduled_at)
);Process the queue with a cron job:
# crontab -e
* * * * * php /path/to/your/app/process-queue.php >> /var/log/email-queue.log 2>&1<?php
// process-queue.php
require_once __DIR__ . '/vendor/autoload.php';
$pdo = new PDO('mysql:host=localhost;dbname=myapp', 'user', 'pass');
$queue = new \App\Queue\EmailQueue($pdo);
$processed = $queue->process(batchSize: 20);
echo date('Y-m-d H:i:s') . " - Processed {$processed} emails\n";Option 2: Symfony Messenger
If you're using Symfony or want a more robust queue:
composer require symfony/messenger symfony/amqp-messenger<?php
// src/Message/SendEmailMessage.php
namespace App\Message;
class SendEmailMessage
{
public function __construct(
public readonly string $to,
public readonly string $subject,
public readonly string $body,
) {}
}
// src/MessageHandler/SendEmailHandler.php
namespace App\MessageHandler;
use App\Email\EmailClient;
use App\Message\SendEmailMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
class SendEmailHandler
{
public function __construct(private readonly EmailClient $client) {}
public function __invoke(SendEmailMessage $message): void
{
$this->client->send($message->to, $message->subject, $message->body);
}
}
// Dispatch from anywhere
$bus->dispatch(new SendEmailMessage(
to: 'user@example.com',
subject: 'Welcome',
body: '<p>Welcome!</p>'
));Testing
PHPUnit makes testing straightforward. Mock the HTTP client to avoid sending real emails:
<?php
// tests/Email/EmailClientTest.php
namespace Tests\Email;
use App\Email\{EmailClient, EmailException};
use PHPUnit\Framework\TestCase;
class EmailsTest extends TestCase
{
public function testWelcomeEmailRendersCorrectly(): void
{
$renderer = new \App\Email\TemplateRenderer(__DIR__ . '/../../templates');
$html = $renderer->render('welcome', [
'name' => 'Jane',
'loginUrl' => 'https://app.yoursite.com/dashboard',
]);
$this->assertStringContainsString('Welcome, Jane', $html);
$this->assertStringContainsString('Go to Dashboard', $html);
$this->assertStringContainsString('https://app.yoursite.com/dashboard', $html);
}
public function testPasswordResetTemplateContainsLink(): void
{
$renderer = new \App\Email\TemplateRenderer(__DIR__ . '/../../templates');
$html = $renderer->render('password_reset', [
'resetUrl' => 'https://app.yoursite.com/reset?token=abc123',
]);
$this->assertStringContainsString('Reset Password', $html);
$this->assertStringContainsString('token=abc123', $html);
$this->assertStringContainsString('expires in 1 hour', $html);
}
public function testEmailExceptionFromRateLimit(): void
{
$exception = EmailException::fromResponse(429, ['error' => 'Too many requests']);
$this->assertTrue($exception->isRetryable());
$this->assertEquals(429, $exception->getStatusCode());
}
public function testEmailExceptionFromAuthError(): void
{
$exception = EmailException::fromResponse(401, ['error' => 'Invalid key']);
$this->assertFalse($exception->isRetryable());
$this->assertEquals(401, $exception->getStatusCode());
}
}Going to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Without this, emails go straight to spam. No exceptions. See our email authentication setup guide for step-by-step instructions.
2. Use a Dedicated Sending Domain
Send from mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.
3. Never Hardcode API Keys
Use environment variables. Load them with vlucas/phpdotenv in development and set them in your server config for production:
// In production (Apache)
SetEnv SEQUENZY_API_KEY sq_your_key
// In production (Nginx + PHP-FPM)
env[SEQUENZY_API_KEY] = sq_your_key
// In production (Docker)
ENV SEQUENZY_API_KEY=sq_your_key4. Rate Limit Email Endpoints
<?php
// src/RateLimiter.php
namespace App;
class RateLimiter
{
public function __construct(
private readonly string $cacheDir,
private readonly int $maxPerHour = 10,
) {
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
}
public function check(string $key): bool
{
$file = $this->cacheDir . '/' . md5($key) . '.json';
$now = time();
$hourAgo = $now - 3600;
$timestamps = [];
if (file_exists($file)) {
$timestamps = json_decode(file_get_contents($file), true) ?? [];
$timestamps = array_filter($timestamps, fn(int $t) => $t > $hourAgo);
}
if (count($timestamps) >= $this->maxPerHour) {
return false;
}
$timestamps[] = $now;
file_put_contents($file, json_encode(array_values($timestamps)), LOCK_EX);
return true;
}
}
// Usage in your endpoint
$limiter = new RateLimiter('/tmp/rate-limits');
if (!$limiter->check("email:{$to}")) {
http_response_code(429);
echo json_encode(['error' => 'Too many emails to this address']);
exit;
}5. Log Everything
<?php
// Simple file-based logger
function logEmail(string $level, string $message, array $context = []): void
{
$timestamp = date('Y-m-d H:i:s');
$contextStr = $context ? json_encode($context) : '';
error_log("[{$timestamp}] [{$level}] {$message} {$contextStr}\n", 3, '/var/log/email.log');
}
// In your email client
logEmail('info', 'Email sent', ['to' => $to, 'subject' => $subject]);
logEmail('error', 'Email failed', ['to' => $to, 'error' => $e->getMessage()]);Production Checklist
- [ ] Domain verified (SPF, DKIM, DMARC)
- [ ] Dedicated sending domain (mail.yourapp.com)
- [ ] API keys in environment variables, not code
- [ ] phpdotenv for local development
- [ ] Email validation with filter_var() on all inputs
- [ ] Rate limiting on public-facing endpoints
- [ ] Background queue for non-blocking sends
- [ ] Retry logic with exponential backoff
- [ ] Structured error handling with EmailException
- [ ] Logging on all send attempts
- [ ] htmlspecialchars() in all templates to prevent XSS
- [ ] Stripe webhook signature verification
Beyond Transactional: Marketing Emails and Sequences
At some point, you'll want more than one-off transactional emails. You'll want to:
- Send onboarding sequences that guide new users through your product over several days
- Run marketing campaigns to announce features or share updates
- Automate lifecycle emails based on what users do (or don't do) in your app
- Track engagement to see which emails get opened and clicked
Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.
Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.
Here's what subscriber management looks like:
<?php
$apiKey = $_ENV['SEQUENZY_API_KEY'];
$baseUrl = 'https://api.sequenzy.com/v1';
// Add a subscriber when they sign up
$ch = curl_init("{$baseUrl}/subscribers");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'email' => 'user@example.com',
'firstName' => 'Jane',
'tags' => ['signed-up'],
'customAttributes' => ['plan' => 'free', 'source' => 'organic'],
]),
]);
curl_exec($ch);
curl_close($ch);
// Tag them when they upgrade
$ch = curl_init("{$baseUrl}/subscribers/tags");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'email' => 'user@example.com',
'tag' => 'customer',
]),
]);
curl_exec($ch);
curl_close($ch);
// Track events to trigger automated sequences
$ch = curl_init("{$baseUrl}/subscribers/events");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer {$apiKey}",
'Content-Type: application/json',
],
CURLOPT_POSTFIELDS => json_encode([
'email' => 'user@example.com',
'event' => 'onboarding.completed',
'properties' => ['completedSteps' => 5],
]),
]);
curl_exec($ch);
curl_close($ch);You set up sequences in the Sequenzy dashboard (onboarding drip, trial conversion, churn prevention), and the API triggers them based on what happens in your app.
FAQ
Should I use cURL or Guzzle?
cURL is built into PHP and has zero dependencies. Guzzle adds a nicer API, PSR-7 compatibility, async requests, and middleware. For simple email sends, cURL is fine. If you're already using Guzzle in your project or need async sends, use Guzzle.
Why not use mail()?
mail() uses the system's sendmail binary with no authentication, no TLS encryption, and no error reporting beyond a boolean return. Most cloud hosts disable it entirely. Even when it works, emails sent via mail() almost always land in spam because there's no SPF/DKIM signing.
Do I need PHPMailer?
Only if you need SMTP (e.g., sending through your company's Exchange server or a specific SMTP relay). For most applications, API-based providers are simpler and more reliable. PHPMailer is a great library, but it solves a different problem than what most modern apps need.
How do I send emails asynchronously in PHP?
PHP is synchronous by default. Your options are: (1) a database-backed queue processed by a cron job, (2) a message queue like RabbitMQ with Symfony Messenger, (3) Laravel Queue if you're in the Laravel ecosystem, or (4) fastcgi_finish_request() to send the response early and continue processing. For most apps, a simple database queue with a cron job is sufficient.
How do I prevent XSS in email templates?
Always use htmlspecialchars() when outputting user data in PHP templates, or use Twig which auto-escapes by default. Never put raw user input into HTML email bodies — someone could inject scripts that execute in certain email clients.
How do I handle bounces and complaints?
Configure a webhook in your email provider's dashboard. The provider sends a POST request to your PHP endpoint when an email bounces or a user complains. Parse the payload and update your subscriber records. Remove hard bounces immediately and investigate soft bounces.
What about PHP 7? Do these examples work?
The examples use PHP 8.1+ features (constructor promotion, readonly, match, named arguments, enums). If you're on PHP 7.4, remove the readonly keywords, replace match with switch, and use traditional constructors. But seriously, upgrade to PHP 8.1+ — the DX improvement is significant.
How do I test emails locally without sending them?
Use Mailpit or MailHog — they run a local SMTP server and web UI where you can see all captured emails. For API-based providers, mock the HTTP calls in your tests. If you use PHPUnit, you can create a mock EmailClient that records sends without making real API calls.
How do I send HTML and plain text versions?
Most email providers accept both html and text parameters. Always include a plain text version for accessibility and spam filter scores. You can auto-generate one by stripping HTML tags: strip_tags($html), but a hand-written plain text version reads better.
Wrapping Up
Here's what we covered:
- Ditch
mail()and use API-based providers with cURL or Guzzle - Build a clean EmailClient class with PHP 8.1+ features
- Structured exceptions with
isRetryable()for smart error handling - PHP or Twig templates for maintainable HTML emails
- Stripe webhooks with HMAC signature verification
- Queue-based sending for non-blocking email delivery
- Production checklist: domain verification, rate limiting, logging
The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start shipping emails.
Frequently Asked Questions
Should I use PHP's mail() function or a dedicated email API?
Never use mail() in production. It depends on the server's local mail transfer agent, offers no delivery tracking, and frequently lands in spam. Use a dedicated provider's SDK via Composer for reliable delivery, analytics, and proper authentication.
How do I install email SDKs in PHP?
Use Composer: composer require vendor/package-name. This installs the SDK and its dependencies. Require Composer's autoloader (require 'vendor/autoload.php') in your script and you can use the SDK immediately.
How do I send HTML emails in PHP?
Pass the HTML string to your email provider's SDK. For building HTML templates, use a templating engine like Twig or Blade. For simple emails, PHP's heredoc syntax (<<<HTML ... HTML) keeps HTML readable inline.
How do I send emails asynchronously in PHP?
Use a queue system like Laravel's Queue, Symfony Messenger, or a standalone solution like php-enqueue with Redis. Push email jobs to the queue and process them with a worker. Plain PHP can use pcntl_fork() but this is fragile for production.
How do I store API keys securely in PHP?
Use environment variables loaded with vlucas/phpdotenv. Access them with $_ENV['API_KEY'] or getenv('API_KEY'). Never hardcode keys in PHP files. Use .env locally and your hosting platform's environment configuration in production.
How do I test email sending in PHP?
Use PHPUnit with mocks. Create an interface for your email service and mock it in tests. Verify that send() was called with the expected parameters. For integration testing, use your provider's sandbox mode or Mailpit for local capture.
Can I send emails from PHP CLI scripts?
Yes. PHP CLI scripts have full access to your installed packages. Create a script that requires Composer's autoloader, initializes the email client, and sends. This is common for cron-triggered emails, batch sends, and admin notifications.
How do I handle email bounces and delivery status in PHP?
Set up a webhook endpoint that your email provider calls for bounce events. Parse the webhook payload to identify bounced addresses and update your database. Most providers handle bounce management automatically if you use their subscriber APIs.
What PHP version do I need for modern email SDKs?
Most current SDKs require PHP 8.1 or later for features like enums, fibers, and readonly properties. PHP 8.2 or 8.3 is recommended. If you're on PHP 7.x, upgrade—older versions are end-of-life and have security vulnerabilities.
How do I add rate limiting to PHP email endpoints?
Use a rate limiting library like nikolaposa/rate-limit with Redis as the backend. Check the rate limit before processing the email send. For frameworks, use built-in throttle middleware (Laravel's ThrottleRequests, Symfony's RateLimiter).