Developer Tutorial

How to Send Email with PHP: mail(), PHPMailer, and the API Approach

Covers the built-in mail() function for basics, PHPMailer for SMTP and HTML email, and a REST API for production apps that need delivery tracking.

9 min read·Updated March 2026

PHP powers a huge share of the web — WordPress, Laravel, Symfony, custom CMSes — and nearly all of it needs to send email. This guide covers every real approach: the built-in mail() for simple cases, PHPMailer for SMTP with TLS and HTML content, and a direct REST API call for production apps where you care about delivery rates, bounce handling, and not managing an SMTP server.

1. mail() vs PHPMailer vs REST API

Each approach has a different scope. Understanding the tradeoffs before you commit saves a painful migration later.

mail() (PHP built-in)

Calls sendmail (or your MTA) directly. Zero dependencies, but limited to whatever is configured in php.ini. Essentially unusable for production — no TLS, no auth, no delivery events.

+ Zero dependencies
+ Works for local dev and simple scripts
– Depends on local MTA configuration (often disabled on shared hosting)
– No TLS, no authentication, emails often go to spam
– No bounce handling or delivery events
– HTML headers must be built manually and are easy to get wrong

PHPMailer (SMTP)

The standard PHP library for SMTP email. Handles TLS, auth, HTML multipart, and attachments. Much better than mail() but still SMTP underneath.

+ Mature, widely used, excellent HTML/attachment support
+ Works with any SMTP relay (Gmail, SES, Mailgun SMTP, etc.)
– Still SMTP — no native webhook support for bounces/complaints
– Connection state to manage; synchronous by default
– SMTP credentials to rotate and secure

REST API via cURL or Guzzle (recommended for apps)

POST JSON to an email API. One API key, delivery webhooks, templates with variables, and no SMTP to manage. Works in shared hosting, serverless PHP, and Laravel queues.

+ Stateless HTTP — works everywhere PHP runs
+ Webhooks for bounces, spam complaints, opens, clicks
+ Deliverability managed by provider (warm IPs, feedback loops)
+ No SMTP credentials to rotate
+ Idempotency keys prevent duplicate sends on retry

2. Sending email with mail()

The built-in function. Useful to understand, but rarely appropriate for production:

<?php

function sendPlainEmail(string $to, string $subject, string $body): bool {
    $from = $_ENV['EMAIL_FROM'] ?? '[email protected]';

    $headers = implode("\r\n", [
        "From: $from",
        "Reply-To: $from",
        "MIME-Version: 1.0",
        "Content-Type: text/plain; charset=UTF-8",
        "X-Mailer: PHP/" . PHP_VERSION,
    ]);

    return mail($to, $subject, $body, $headers);
}

// Usage
$sent = sendPlainEmail(
    to: '[email protected]',
    subject: 'Your order shipped',
    body: "Hi, your order #1234 has shipped.\nTrack it at https://example.com/track/1234"
);

if (!$sent) {
    error_log("mail() failed — check php.ini sendmail_path");
}

What goes wrong with mail() in real deployments:

  • mail() returns true when it successfully hands the message to sendmail — not when it's delivered. You get no failure signal if the SMTP relay rejects it.
  • Most shared hosting providers and cloud VMs (AWS EC2, GCP Compute) block outbound SMTP on port 25 by default. mail() silently fails.
  • HTML email requires extra headers and a multipart body — the raw string concatenation is error-prone. One bad header and your HTML renders as text.
  • No TLS. Messages sent via mail() travel unencrypted between servers, which is a problem for password resets and sensitive content.

3. HTML email with PHPMailer

PHPMailer handles the MIME complexity and TLS correctly. Install it with Composer:

composer require phpmailer/phpmailer
<?php

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
use PHPMailer\PHPMailer\Exception;

function sendHtmlEmail(string $to, string $subject, string $html, string $text): void {
    $mail = new PHPMailer(exceptions: true);

    $mail->isSMTP();
    $mail->Host       = $_ENV['SMTP_HOST'];
    $mail->SMTPAuth   = true;
    $mail->Username   = $_ENV['SMTP_USER'];
    $mail->Password   = $_ENV['SMTP_PASSWORD'];
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
    $mail->Port       = 587;

    $mail->setFrom($_ENV['EMAIL_FROM'], $_ENV['EMAIL_FROM_NAME'] ?? '');
    $mail->addAddress($to);

    $mail->isHTML(true);
    $mail->Subject = $subject;
    $mail->Body    = $html;
    $mail->AltBody = $text; // Plain text fallback — don't skip this

    $mail->send();
}

// Usage
try {
    sendHtmlEmail(
        to: '[email protected]',
        subject: 'Reset your password',
        html: '<p>Click <a href="https://app.example.com/reset?token=abc123">here</a> to reset your password. This link expires in 1 hour.</p>',
        text: 'Reset your password: https://app.example.com/reset?token=abc123 (expires in 1 hour)',
    );
} catch (Exception $e) {
    error_log("Email failed: {$e->getMessage()}");
    throw $e;
}

PHPMailer with attachments:

$mail->addAttachment('/tmp/invoice-1234.pdf', 'Invoice-1234.pdf');
// Or from a string (without writing to disk):
$mail->addStringAttachment($pdfContent, 'Invoice-1234.pdf', 'base64', 'application/pdf');

4. Sending email via REST API

Two options: raw cURL (no dependencies) or Guzzle (cleaner for apps already using it):

Option A: cURL (no dependencies)

<?php

function sendEmail(string $to, string $subject, string $html, string $text, ?string $idempotencyKey = null): array {
    $payload = json_encode([
        'from'    => $_ENV['EMAIL_FROM'],
        'to'      => $to,
        'subject' => $subject,
        'html'    => $html,
        'text'    => $text,
    ], JSON_THROW_ON_ERROR);

    $headers = [
        'Authorization: Bearer ' . $_ENV['TINYSEND_API_KEY'],
        'Content-Type: application/json',
        'Content-Length: ' . strlen($payload),
    ];

    if ($idempotencyKey !== null) {
        $headers[] = 'Idempotency-Key: ' . $idempotencyKey;
    }

    $ch = curl_init('https://api.tinysend.co/v1/emails');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => $payload,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_TIMEOUT        => 10,
    ]);

    $response = curl_exec($ch);
    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $error = curl_error($ch);
    curl_close($ch);

    if ($error) {
        throw new RuntimeException("cURL error: $error");
    }

    $data = json_decode($response, true, flags: JSON_THROW_ON_ERROR);

    if ($statusCode >= 400) {
        throw new RuntimeException("Email API error $statusCode: " . ($data['message'] ?? $response));
    }

    return $data;
}

// Usage
$result = sendEmail(
    to: '[email protected]',
    subject: 'Your account is ready',
    html: '<p>Hi <strong>Alex</strong>, sign in at <a href="https://app.example.com">app.example.com</a></p>',
    text: 'Hi Alex, sign in at https://app.example.com',
    idempotencyKey: "welcome-{$userId}",
);
echo "Sent email, id: {$result['id']}\n";

Option B: Guzzle (for apps already using it)

composer require guzzlehttp/guzzle
<?php

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;

class EmailService {
    private Client $client;

    public function __construct() {
        $this->client = new Client([
            'base_uri' => 'https://api.tinysend.co',
            'timeout'  => 10,
            'headers'  => [
                'Authorization' => 'Bearer ' . $_ENV['TINYSEND_API_KEY'],
            ],
        ]);
    }

    public function send(string $to, string $subject, string $html, string $text): array {
        try {
            $response = $this->client->post('/v1/emails', [
                'json' => [
                    'from'    => $_ENV['EMAIL_FROM'],
                    'to'      => $to,
                    'subject' => $subject,
                    'html'    => $html,
                    'text'    => $text,
                ],
            ]);
            return json_decode($response->getBody(), true, flags: JSON_THROW_ON_ERROR);
        } catch (ClientException $e) {
            $statusCode = $e->getResponse()->getStatusCode();
            $body = (string) $e->getResponse()->getBody();
            throw new RuntimeException("Email API $statusCode: $body", previous: $e);
        }
    }
}

5. Laravel and Symfony integration

Laravel

Laravel's Mail facade supports SMTP out of the box. For REST API sends with webhooks, a custom notification channel or a simple service class is cleaner:

# .env — SMTP approach (works with tinysend SMTP bridge)
MAIL_MAILER=smtp
MAIL_HOST=smtp.tinysend.co
MAIL_PORT=587
MAIL_USERNAME=your_smtp_username
MAIL_PASSWORD=your_smtp_password
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="Your App"
<?php
// For REST API sends — app/Services/EmailService.php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class EmailService
{
    public function send(string $to, string $subject, string $html, string $text): array
    {
        $response = Http::withToken(config('services.tinysend.api_key'))
            ->timeout(10)
            ->post('https://api.tinysend.co/v1/emails', [
                'from'    => config('mail.from.address'),
                'to'      => $to,
                'subject' => $subject,
                'html'    => $html,
                'text'    => $text,
            ]);

        $response->throw(); // Throws on 4xx/5xx
        return $response->json();
    }
}

// In a Listener or Job (runs in queue for non-blocking sends):
// class SendWelcomeEmail implements ShouldQueue {
//     public function handle(UserRegistered $event): void {
//         app(EmailService::class)->send(
//             to: $event->user->email,
//             subject: "Welcome, {$event->user->first_name}!",
//             html: view('emails.welcome', ['user' => $event->user])->render(),
//             text: "Welcome to our app. Sign in at https://app.example.com",
//         );
//     }
// }

Symfony Mailer

Symfony Mailer uses SMTP transports configured via DSN. For REST API sends, bypass it with a service:

# config/packages/mailer.yaml
framework:
    mailer:
        dsn: 'smtp://user:[email protected]:587'

# Or use the HTTP transport with a Symfony HttpClient-based service:
# services.yaml
# App\Service\EmailService:
#     arguments:
#         $client: '@http_client'
#         $apiKey: '%env(TINYSEND_API_KEY)%'
#         $from: '%env(EMAIL_FROM)%'

6. Error handling and retries

Two categories of errors: validation (your payload is wrong — don't retry) and transient (network issues, 5xx — retry with backoff):

<?php

function sendEmailWithRetry(string $to, string $subject, string $html, string $text, int $maxRetries = 2): array {
    $lastException = null;

    for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
        try {
            return sendEmail($to, $subject, $html, $text);
        } catch (RuntimeException $e) {
            // Parse the status code from the message — or refactor to a typed exception
            $isValidationError = str_contains($e->getMessage(), '422') || str_contains($e->getMessage(), '400');

            if ($isValidationError) {
                throw $e; // Fix the payload — retrying won't help
            }

            $lastException = $e;

            if ($attempt < $maxRetries) {
                $waitSeconds = 2 ** $attempt; // 1s, 2s
                sleep($waitSeconds);
            }
        }
    }

    throw new RuntimeException(
        "Email send failed after $maxRetries retries",
        previous: $lastException,
    );
}

// For Laravel or Symfony, push the send into a queued job instead of inline retries:
// SendTransactionalEmail::dispatch($to, $subject, $html, $text)
//     ->onQueue('emails')
//     ->delay(now()->addSeconds(2));
// Use job retries + backoff config instead of manual sleep.

In production Laravel/Symfony apps, prefer pushing email sends to a queue (Redis, SQS) rather than inline retries. Queue workers handle backoff, dead-letter queues, and failure visibility without blocking your HTTP request lifecycle.

7. Deliverability checklist before you launch

SPF, DKIM, and DMARC are configured
Without these DNS records, Gmail and Outlook will reject or spam-folder your email. See the complete DKIM/SPF/DMARC guide.
TINYSEND_API_KEY is in .env, not in source code
Run git log -S TINYSEND_API_KEY before your first push. Add .env to .gitignore if it isn't already.
Bounce webhook handler is live
Hard bounces must be suppressed immediately. An unhandled bounce rate above 2% will get your sending account suspended across virtually all providers.
Always include a plain-text body
HTML-only emails score worse in spam filters. Always set AltBody in PHPMailer (or text in the API payload) — make it readable, not just tag-stripped HTML.
Don't use mail() in production
It bypasses SMTP authentication, ignores TLS, and gives you no delivery feedback. Even for low-volume apps, PHPMailer or a REST API is worth the 5-minute setup.
Sends are queued, not inline
In Laravel/Symfony, push email sends to a queue so a slow API call doesn't block your HTTP response. Use ShouldQueue on your listener or dispatch a job.

SMTP vs REST API for PHP

Many PHP hosts block outbound SMTP connections. REST API sends over HTTPS work on every host, including shared hosting and serverless PHP (AWS Lambda via Bref, Vercel). No SMTP ports to unblock. See our SMTP vs email API comparison for the full breakdown.

Send your first email from PHP in 2 minutes

tinysend gives you a clean REST API, templates with variables, bounce handling, and BYOK — so you can use your existing SES or Postmark account as the backend. Works with Laravel, Symfony, or plain PHP.

Get your API key →