How to Send Emails in PHP (2026 Guide)

PHP's built-in mail() function is the oldest email API on the web. It's also the worst. No authentication, no TLS, no error handling, and most hosting providers have it disabled or heavily restricted.
This guide covers how to send emails from PHP properly: using API-based providers that handle deliverability, retries, and bounce processing. Plus PHPMailer for SMTP when you need it. All examples work with vanilla PHP or any framework.
mail() vs PHPMailer vs API Providers
// mail() - don't use this in production
mail("user@example.com", "Hello", "Body text");
// No error handling, no auth, no TLS, goes to spam
// PHPMailer - good for SMTP
$mail = new PHPMailer();
$mail->isSMTP();
$mail->Host = "smtp.gmail.com";
// 20+ lines of config...
// API provider - one HTTP call
$ch = curl_init("https://api.sequenzy.com/v1/transactional/send");
// Clean, reliable, handles deliverability for youUse mail() never. Use PHPMailer if you need a specific SMTP server. Use an API provider for everything else.
Pick a Provider
- Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management from one API. Has native Stripe integration.
- Resend is developer-friendly with a clean API. Good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
- SendGrid is the enterprise option. Feature-rich, sometimes complex. Good for high volume.
Install
# No SDK needed - just use cURL or Guzzle
composer require guzzlehttp/guzzlecomposer require resend/resend-phpcomposer require sendgrid/sendgridAdd your API key. Use environment variables or a .env file with vlucas/phpdotenv:
SEQUENZY_API_KEY=sq_your_api_key_hereRESEND_API_KEY=re_your_api_key_hereSENDGRID_API_KEY=SG.your_api_key_hereCreate an Email Helper
<?php
class Email
{
private string $apiKey;
private string $baseUrl = 'https://api.sequenzy.com/v1';
public function __construct()
{
$this->apiKey = $_ENV['SEQUENZY_API_KEY'] ?? getenv('SEQUENZY_API_KEY');
}
public function send(string $to, string $subject, string $body): array
{
$ch = curl_init("{$this->baseUrl}/transactional/send");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
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);
curl_close($ch);
if ($httpCode >= 400) {
throw new RuntimeException("Email send failed: {$response}");
}
return json_decode($response, true);
}
}<?php
use Resend\Client;
class Email
{
private Client $resend;
public function __construct()
{
$this->resend = Resend::client(
$_ENV['RESEND_API_KEY'] ?? getenv('RESEND_API_KEY')
);
}
public function send(string $to, string $subject, string $html): array
{
return $this->resend->emails->send([
'from' => 'Your App <noreply@yourdomain.com>',
'to' => $to,
'subject' => $subject,
'html' => $html,
]);
}
}<?php
use SendGrid;
use SendGrid\Mail\Mail;
class Email
{
private SendGrid $sg;
public function __construct()
{
$this->sg = new SendGrid(
$_ENV['SENDGRID_API_KEY'] ?? getenv('SENDGRID_API_KEY')
);
}
public function send(string $to, string $subject, string $html): object
{
$email = new Mail();
$email->setFrom('noreply@yourdomain.com');
$email->setSubject($subject);
$email->addTo($to);
$email->addContent('text/html', $html);
$response = $this->sg->send($email);
if ($response->statusCode() >= 400) {
throw new RuntimeException("Email send failed: " . $response->body());
}
return $response;
}
}Send Your First Email
<?php
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/Email.php';
$email = new Email();
$result = $email->send(
'user@example.com',
'Hello from PHP',
'<p>Your app is sending emails.</p>'
);
echo "Sent: " . json_encode($result) . PHP_EOL;<?php
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/Email.php';
$email = new Email();
$result = $email->send(
'user@example.com',
'Hello from PHP',
'<p>Your app is sending emails.</p>'
);
echo "Sent: " . json_encode($result) . PHP_EOL;<?php
require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/src/Email.php';
$email = new Email();
$result = $email->send(
'user@example.com',
'Hello from PHP',
'<p>Your app is sending emails.</p>'
);
echo "Sent successfully" . PHP_EOL;php send.phpSend from a Web Endpoint
<?php
// public/api/send-welcome.php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../src/Email.php';
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 = $data['email'] ?? null;
$name = $data['name'] ?? null;
if (!$to || !$name) {
http_response_code(400);
echo json_encode(['error' => 'email and name are required']);
exit;
}
try {
$email = new Email();
$result = $email->send(
$to,
"Welcome, {$name}",
"<h1>Welcome, {$name}</h1><p>Your account is ready.</p>"
);
echo json_encode($result);
} catch (Exception $e) {
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"}'HTML Email Templates
Use a simple template function to keep HTML separate from logic:
<?php
// src/templates.php
function welcomeTemplate(string $name, string $loginUrl): string
{
return <<<HTML
<!DOCTYPE html>
<html>
<body style="font-family: sans-serif; background: #f6f9fc; padding: 40px 0;">
<div style="max-width: 480px; margin: 0 auto; background: #fff; padding: 40px; border-radius: 8px;">
<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="{$loginUrl}"
style="display:inline-block; background:#f97316; color:#fff; padding:12px 24px; border-radius:6px; text-decoration:none; font-weight:600; margin-top:16px;">
Go to Dashboard
</a>
</div>
</body>
</html>
HTML;
}
function passwordResetTemplate(string $resetUrl): string
{
return <<<HTML
<h2>Password Reset</h2>
<p>Click below to reset your password. This link expires in 1 hour.</p>
<a href="{$resetUrl}"
style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
Reset Password
</a>
<p style="color:#6b7280;font-size:14px;margin-top:24px;">
If you didn't request this, ignore this email.
</p>
HTML;
}Use with any provider:
<?php
require_once __DIR__ . '/src/Email.php';
require_once __DIR__ . '/src/templates.php';
$email = new Email();
$email->send(
'jane@example.com',
'Welcome to our app',
welcomeTemplate('Jane', 'https://app.yoursite.com/login')
);Error Handling and Retries
<?php
// src/EmailWithRetry.php
class EmailWithRetry
{
private Email $email;
private int $maxRetries;
public function __construct(int $maxRetries = 3)
{
$this->email = new Email();
$this->maxRetries = $maxRetries;
}
public function send(string $to, string $subject, string $body): array
{
$lastException = null;
for ($attempt = 0; $attempt <= $this->maxRetries; $attempt++) {
try {
return $this->email->send($to, $subject, $body);
} catch (RuntimeException $e) {
$lastException = $e;
error_log("Email attempt {$attempt} failed for {$to}: {$e->getMessage()}");
if ($attempt < $this->maxRetries) {
$delay = pow(2, $attempt);
sleep($delay);
}
}
}
throw $lastException;
}
}Going to Production
1. Verify Your Domain
Add SPF, DKIM, and DMARC DNS records through your provider's dashboard. Without this, emails go to spam.
2. Never Use mail()
If you see mail() in your codebase, replace it. It's unreliable, unencrypted, and usually disabled on modern hosting.
3. Use Environment Variables
Never hardcode API keys. Use getenv() or a .env loader:
composer require vlucas/phpdotenv$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();4. Rate Limit Email Endpoints
<?php
// Simple file-based rate limiter
function canSendEmail(string $to, int $maxPerHour = 10): bool
{
$file = sys_get_temp_dir() . '/email_rate_' . md5($to);
$timestamps = file_exists($file) ? json_decode(file_get_contents($file), true) : [];
$hourAgo = time() - 3600;
$timestamps = array_filter($timestamps, fn($t) => $t > $hourAgo);
if (count($timestamps) >= $maxPerHour) return false;
$timestamps[] = time();
file_put_contents($file, json_encode(array_values($timestamps)));
return true;
}Beyond Transactional
Once you need onboarding sequences, marketing campaigns, or lifecycle automation, Sequenzy handles it all from one API. Transactional sends, campaigns, automated sequences, and native Stripe integration.
// Add subscriber
$ch = curl_init('https://api.sequenzy.com/v1/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'],
]),
]);
curl_exec($ch);
curl_close($ch);Wrapping Up
- Ditch
mail()and use API providers - Create a reusable Email class for clean, consistent sending
- Use HTML templates with heredoc syntax
- Add retry logic for production reliability
- Verify your domain before going live
Pick your provider, copy the patterns, and start sending.