5 ways to send email with PHP

Send email from PHP with mail(), PHPMailer, Symfony Mailer, an email API, or a notification API. Working code for each, plus how to choose between them.

Updated Sep 11, 2026

Last updated: September 2026. Package versions verified against Packagist on 2026-09-11.

PHP is one of the few languages with email in its standard library, and that turns out to be a trap: mail() is the easiest thing to reach for and the least likely to actually reach an inbox.

There are five realistic options, and they sort cleanly by how much of deliverability you are willing to own.

OptionBest forWhat it costs you
mail()Local tests, nothing elseNo SMTP auth, so mail is unsigned and easily filtered
PHPMailerSending through an SMTP server you have credentials forYou manage credentials and deliverability
Symfony MailerAny PHP project, especially Symfony with Twig templatesStill SMTP-bound unless you add a provider bridge
An email APITransactional email at volumeTied to one provider
A notification APIEmail plus SMS, push, and in-app from one callOne more service to learn

Which option should you pick?

  • Skip mail() for anything users will see. It is in the guide because it is what people find first, and the section explains why it disappoints.
  • PHPMailer if you have SMTP credentials and want the shortest path to a well-formed message with attachments.
  • Symfony Mailer if you are in Symfony, or you want a modern component API in any PHP project. It is not Symfony-only, despite the name.
  • An email API for transactional mail at volume, where you want delivery events and no port problems.
  • A notification API once email is one channel among several.

1. Send with the mail() function

PHP's mail() hands your message to a local mail transfer agent, usually sendmail, on the web server.

Understand the limitation before you use it. mail() cannot authenticate against an external SMTP server. Your mail therefore leaves from your web server's IP, which usually has no SPF record covering it, no DKIM signing, and no sending reputation. Receiving servers treat that as suspicious, so it lands in spam or is rejected. It also has no attachment support and opens a connection per message.

It is worth knowing for local testing and legacy code. It is not a way to email your users.

Send the message

<?php
$to = 'erika@example.com';
$subject = 'Your order shipped';
$message = '<html><body><p>Your order shipped. Track it in the app.</p></body></html>';
// Append with .= after the first header. Using = each time overwrites
// the previous one, which silently drops your From: line.
$headers = 'From: Acme <orders@example.com>' . "\r\n";
$headers .= 'MIME-Version: 1.0' . "\r\n";
$headers .= 'Content-Type: text/html; charset=UTF-8' . "\r\n";
if (!mail($to, $subject, $message, $headers)) {
error_log('mail() returned false; check the local MTA');
}

Two things to notice. Headers are appended with .=, not reassigned. And mail() returns a boolean that means "the MTA accepted this," not "it was delivered," so checking it is worth doing while understanding what it does not tell you.

Where it gets thin: everything. No authentication, no attachments, no delivery status, and a real chance the message never arrives.

2. Send with PHPMailer

PHPMailer is the long-standing PHP mail library. It connects to an SMTP server with authentication, builds MIME properly, and handles attachments and HTML bodies.

What you need first

  • PHP 7.4 or newer, and Composer.
  • SMTP credentials: host, port, username, and password.
  • For Gmail, two-factor authentication enabled and an app password generated in its place. Google removed the "less secure apps" setting in 2022, so an account password will not authenticate.

Install

composer require phpmailer/phpmailer

Verified against phpmailer/phpmailer v7.1.1.

Send the message

<?php
require __DIR__ . '/vendor/autoload.php';
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\SMTP;
$mail = new PHPMailer(true); // true enables exceptions
try {
$mail->isSMTP();
$mail->Host = 'smtp.example.com';
$mail->SMTPAuth = true;
$mail->Username = getenv('SMTP_USERNAME');
$mail->Password = getenv('SMTP_PASSWORD');
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
$mail->setFrom('orders@example.com', 'Acme');
$mail->addAddress('erika@example.com', 'Erika');
$mail->isHTML(true); // without this, HTML arrives as visible tags
$mail->Subject = 'Your order shipped';
$mail->Body = '<h1>Your order shipped</h1><p>Track it in the app.</p>';
$mail->AltBody = 'Your order shipped. Track it in the app.';
$mail->send();
echo 'sent';
} catch (Exception $e) {
// PHPMailer's own exception, not the global one.
error_log("Send failed: {$mail->ErrorInfo}");
}

Three details worth calling out:

  • isHTML(true) is required. Setting an HTML Body without it sends the markup as plain text, which is one of the most common PHPMailer complaints.
  • AltBody gives clients that cannot render HTML a plain-text alternative. Its presence also helps with spam scoring.
  • ENCRYPTION_STARTTLS on 587, or ENCRYPTION_SMTPS on 465. Match the constant to the port. The class constants are clearer than the older "tls" string.

Enable $mail->SMTPDebug = SMTP::DEBUG_SERVER; while diagnosing a connection, and remove it before deploying. It prints the full SMTP conversation, including headers.

Where it gets thin: one SMTP server, message copy in your source, an accept-or-reject as your only feedback, and blocked ports as a deployment risk.

3. Send with Symfony Mailer

Symfony Mailer is the modern PHP mail component. It has a cleaner API than PHPMailer, a DSN-based transport configuration, and bridges for provider APIs as well as SMTP.

It is not Symfony-only. This is worth stating plainly because it is a common misconception and older versions of this guide asserted it as a limitation. symfony/mailer is a standalone component: install it in Laravel, WordPress, or a plain script and construct a transport yourself.

Install

composer require symfony/mailer

Verified against symfony/mailer v8.1.5. If you want to send through a provider's API rather than SMTP, add its bridge, for example symfony/sendgrid-mailer or symfony/amazon-mailer.

In a plain PHP script

<?php
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mailer\Transport;
use Symfony\Component\Mime\Email;
// A DSN, so swapping SMTP for a provider API is a one-line change.
$transport = Transport::fromDsn(getenv('MAILER_DSN'));
$mailer = new Mailer($transport);
$email = (new Email())
->from('Acme <orders@example.com>')
->to('erika@example.com')
->subject('Your order shipped')
->text('Your order shipped. Track it in the app.')
->html('<h1>Your order shipped</h1><p>Track it in the app.</p>');
$mailer->send($email);

With MAILER_DSN in your environment:

MAILER_DSN="smtp://user:app-password@smtp.example.com:587"

URL-encode the password if it contains special characters, or the DSN will fail to parse. This is a genuinely common cause of a confusing startup error.

In a Symfony application, inject MailerInterface and let the framework build the transport from MAILER_DSN:

<?php
// src/Controller/EmailController.php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;
use Symfony\Component\Routing\Attribute\Route;
class EmailController extends AbstractController
{
#[Route('/email', name: 'send_email')]
public function sendEmail(MailerInterface $mailer): Response
{
$email = (new Email())
->from('orders@example.com')
->to('erika@example.com')
->subject('Your order shipped')
->html('<p>Your order shipped.</p>');
try {
$mailer->send($email);
} catch (TransportExceptionInterface $e) {
return new Response('Could not send', 500);
}
return new Response('Sent');
}
}

Note the PHP 8 attribute #[Route(...)] rather than the older @Route annotation, and that the method returns a Response on every path, which its declared return type requires.

For templated HTML, swap Email for TemplatedEmail and render with Twig, which is the main reason to prefer this component in a Symfony app.

Where it gets thin: with an SMTP DSN you have the same constraints as PHPMailer. A provider bridge removes the port problem but ties you to that provider.

4. Send with an email API

An HTTPS API removes port and TLS problems, and gives you delivery events, bounce handling, and a message ID. SendGrid, Amazon SES, Postmark, Mailgun, and Resend all cover PHP.

What you need first

  • An account with the provider, and an API key.
  • A verified sender identity or domain. Sending from an unverified address is rejected, and it is the usual first-attempt failure.

Install

composer require sendgrid/sendgrid

Send the message

<?php
require __DIR__ . '/vendor/autoload.php';
use SendGrid\Mail\Mail;
$email = new Mail();
$email->setFrom('orders@example.com', 'Acme');
$email->addTo('erika@example.com', 'Erika');
$email->setSubject('Your order shipped');
$email->addContent('text/plain', 'Your order shipped. Track it in the app.');
$email->addContent('text/html', '<h1>Your order shipped</h1>');
// Read the variable, do not pass the placeholder as the variable name.
$sendgrid = new \SendGrid(getenv('SENDGRID_API_KEY'));
try {
$response = $sendgrid->send($email);
if ($response->statusCode() >= 300) {
error_log('SendGrid rejected the send: ' . $response->body());
}
} catch (Exception $e) {
error_log('Send failed: ' . $e->getMessage());
}

Two things. getenv() takes the name of an environment variable, so getenv('SENDGRID_API_KEY') is right and getenv('<YOUR-API-KEY>') returns false. And a successful send is 202 Accepted, not 200, so check for a status below 300 rather than equality with 200.

Adding both a text/plain and a text/html part is worth the extra line: some clients prefer text, and its absence counts against you in spam scoring.

Where it gets thin: one provider, copy in your source, and everything above delivery is yours to build.

5. Send with a notification API

The four options above solve delivery for one channel. Here is what tends to arrive afterward:

  • Copy is a string literal in your code, so a wording change is a deploy.
  • Someone unsubscribes and you now own a suppression list.
  • The same event should reach one user by email and another by push.
  • Your provider has a bad hour and there is no second path.
  • Twenty events fire in a minute and one user gets twenty emails.
  • Someone asks how many notifications failed last week and you cannot answer.

A notification API such as Courier sits above your email provider and handles those. You keep SendGrid, SES, or your SMTP relay delivering, and your PHP stops knowing which one it is.

What the layer gives you:

  • Automatic failover between providers when one starts erroring.
  • Journeys for multi-step sequences, with digest and throttling nodes.
  • One send endpoint for email, SMS, push, in-app, Slack, and Microsoft Teams.
  • Per-user, per-channel preferences enforced before a send.
  • Templates edited outside your codebase, with localization per user.
  • One log covering every channel.

Set it up

Create a free account: the Developer plan includes 10,000 messages a month, and past that it's $0.005 per message, the same rate on every channel. Then connect an email provider under Channels using the credentials it already gave you. Same provider as above; you are not changing who delivers.

Install

composer require trycourier/courier

Verified against trycourier/courier v7.4.0, which requires PHP 8.1 or newer. Note the package name: the SDK's own README still shows composer require trycourier/courier-php, but trycourier/courier is the one that resolves on Packagist. Pin the major version rather than floating, since this SDK moved from v5 to v7 in about a month.

Send the message

<?php
require __DIR__ . '/vendor/autoload.php';
use Courier\Client;
$client = new Client(apiKey: getenv('COURIER_API_KEY'));
$result = $client->send->message(
message: [
'to' => ['user_id' => 'user-123'],
'template' => 'ORDER_SHIPPED',
'data' => [
'name' => 'Erika',
'tracking_url' => 'https://example.com/t/abc',
],
],
);
echo $result->requestId, PHP_EOL;

Nothing in that call names email. Routing and the user's stored preferences decide the channel, so moving a notification to push, or sending both, is configuration rather than a code change.

To send to an address rather than a stored user, and define content inline instead of referencing a template:

$result = $client->send->message(
message: [
'to' => ['email' => 'erika@example.com'],
'content' => [
'title' => 'Your order shipped',
'body' => 'Track it anytime in the app.',
],
'routing' => ['method' => 'single', 'channels' => ['email']],
],
);

The SDK uses named arguments, which is why it needs PHP 8.1. The returned requestId is what you search on in the Courier logs to see queued, sent, delivered, and undeliverable states along with any provider error.

Where it gets thin: one more service in the path, and one more concept before your first send.

Building this with an AI agent

If you are writing this in Cursor, Claude Code, or Codex, connect the agent to Courier directly instead of pasting snippets. It then works from the current SDK shapes rather than the 2022-era API that most training data still contains, which is the difference between PHP that compiles and PHP that does not.

The MCP server gives the agent typed tool calls. In Claude Code:

claude mcp add --transport http courier https://mcp.courier.com --header api_key:YOUR_API_KEY

Courier Skills adds the practices that are easy to get wrong, such as never batching a one-time passcode:

npx skills add trycourier/courier-skills

Both sit alongside the Courier CLI for scripting and CI. courier.com/agents collects the setup for Cursor, Claude Code, Codex, and VS Code in one place, and Build with AI has the machine-readable docs index an agent can fetch on its own.

Then ask in plain language: "email a receipt when a payment succeeds, and respect the user's preferences." The agent picks the right primitive, and knows which channels need a recorded opt-in.

This matters for the same reason this guide version-stamps every snippet. An agent working from stale training data will confidently write a send call that no longer exists.

Comparing the five options

mail()PHPMailerSymfony MailerEmail APINotification API
Packagenone, built inphpmailer/phpmailer v7.1.1symfony/mailer v8.1.5sendgrid/sendgridtrycourier/courier v7.4.0
SMTP authenticationnoyesyesn/a, HTTPSn/a, HTTPS
Attachmentsnoyesyesyesyes
HTML with text fallbackheaders by handBody + AltBodyhtml() + text()two content partsyes
Blocked-port riskn/a, local MTArealreal with an SMTP DSNnono
Framework-independentyesyesyes, despite the nameyesyes
Delivery statusboolean from the MTAaccept or rejectaccept or rejectper-message analyticsone log, all channels
Templates outside your codenonoTwig, in your repoprovider-hostedyes
Preferences and unsubscribesnoyou build ityou build itpartialbuilt in
Other channelsnonenonenonenoneSMS, push, in-app, chat
Best forlocal testsSMTP credentials in handmodern PHP projectstransactional at volumeemail plus other channels

If you are testing locally, mail() is fine and nothing else is simpler. For anything a user will see, start at PHPMailer or Symfony Mailer if you have SMTP credentials, and an email API if you do not. If you can already name a second channel you will need, the notification layer saves you writing preferences and failover yourself.

Next steps


FAQ

Frequently asked questions

The built-in mail() function is the shortest to try: one call with a recipient, subject, body, and headers. It is also the worst choice for anything real, because it cannot authenticate against an SMTP server, so your mail is sent directly from your web server and is easily filtered as spam. For production use PHPMailer, Symfony Mailer, or an email API.

One API, every channel

Ship notifications without the boilerplate

Courier gives you one API for email, SMS, push, and chat, with templates, routing, retries, and delivery logs built in.

Last updated Sep 11, 2026. Code samples are illustrative; provider APIs and pricing change over time, so check each provider’s docs before relying on them.