Developer Tutorial
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.
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.
Each approach has a different scope. Understanding the tradeoffs before you commit saves a painful migration later.
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.
The standard PHP library for SMTP email. Handles TLS, auth, HTML multipart, and attachments. Much better than mail() but still SMTP underneath.
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.
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.mail() silently fails.mail() travel unencrypted between servers, which is a problem for password resets and sensitive content.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');Two options: raw cURL (no dependencies) or Guzzle (cleaner for apps already using it):
<?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";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);
}
}
}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 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)%'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.
git log -S TINYSEND_API_KEY before your first push. Add .env to .gitignore if it isn't already.AltBody in PHPMailer (or text in the API payload) — make it readable, not just tag-stripped HTML.ShouldQueue on your listener or dispatch a job.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.
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 →