Back to Blog

How to Send Emails in PHP (2026 Guide)

13 min read

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 you

Use 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

Terminal
# No SDK needed - just use cURL or Guzzle
composer require guzzlehttp/guzzle
Terminal
composer require resend/resend-php
Terminal
composer require sendgrid/sendgrid

Add your API key. Use environment variables or a .env file with vlucas/phpdotenv:

.env
SEQUENZY_API_KEY=sq_your_api_key_here
.env
RESEND_API_KEY=re_your_api_key_here
.env
SENDGRID_API_KEY=SG.your_api_key_here

Create an Email Helper

src/Email.php
<?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);
  }
}
src/Email.php
<?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,
      ]);
  }
}
src/Email.php
<?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

send.php
<?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;
send.php
<?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;
send.php
<?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.php

Send 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

  1. Ditch mail() and use API providers
  2. Create a reusable Email class for clean, consistent sending
  3. Use HTML templates with heredoc syntax
  4. Add retry logic for production reliability
  5. Verify your domain before going live

Pick your provider, copy the patterns, and start sending.