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.
| Option | Best for | What it costs you |
|---|---|---|
mail() | Local tests, nothing else | No SMTP auth, so mail is unsigned and easily filtered |
| PHPMailer | Sending through an SMTP server you have credentials for | You manage credentials and deliverability |
| Symfony Mailer | Any PHP project, especially Symfony with Twig templates | Still SMTP-bound unless you add a provider bridge |
| An email API | Transactional email at volume | Tied to one provider |
| A notification API | Email plus SMS, push, and in-app from one call | One more service to learn |
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.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.
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
Install
composer require phpmailer/phpmailer
Verified against phpmailer/phpmailer v7.1.1.
Send the message
<?phprequire __DIR__ . '/vendor/autoload.php';use PHPMailer\PHPMailer\Exception;use PHPMailer\PHPMailer\PHPMailer;use PHPMailer\PHPMailer\SMTP;$mail = new PHPMailer(true); // true enables exceptionstry {$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.
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
<?phprequire __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.phpnamespace 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.
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
Install
composer require sendgrid/sendgrid
Send the message
<?phprequire __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.
The four options above solve delivery for one channel. Here is what tends to arrive afterward:
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:
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
<?phprequire __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.
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.
mail() | PHPMailer | Symfony Mailer | Email API | Notification API | |
|---|---|---|---|---|---|
| Package | none, built in | phpmailer/phpmailer v7.1.1 | symfony/mailer v8.1.5 | sendgrid/sendgrid | trycourier/courier v7.4.0 |
| SMTP authentication | no | yes | yes | n/a, HTTPS | n/a, HTTPS |
| Attachments | no | yes | yes | yes | yes |
| HTML with text fallback | headers by hand | Body + AltBody | html() + text() | two content parts | yes |
| Blocked-port risk | n/a, local MTA | real | real with an SMTP DSN | no | no |
| Framework-independent | yes | yes | yes, despite the name | yes | yes |
| Delivery status | boolean from the MTA | accept or reject | accept or reject | per-message analytics | one log, all channels |
| Templates outside your code | no | no | Twig, in your repo | provider-hosted | yes |
| Preferences and unsubscribes | no | you build it | you build it | partial | built in |
| Other channels | none | none | none | none | SMS, push, in-app, chat |
| Best for | local tests | SMTP credentials in hand | modern PHP projects | transactional at volume | email 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.
FAQ
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.mail() sends directly from your web server, which typically has no SPF record covering it, no DKIM signing, and no sending reputation, so receiving servers distrust it. Sending through an authenticated SMTP provider or an email API means your mail is signed and sent from an address with a reputation, which is most of the fix.$headers = "..." assignments keeps only the last one, so the From: line disappears. Use .= for every header after the first, or better, use a library that assembles headers for you. A message with no From header is rejected outright by many servers.symfony/mailer and constructing a Mailer with a transport. It integrates especially neatly with Symfony and Twig, which is where the misconception comes from, but the framework is not a requirement.composer require trycourier/courier, then require Composer's autoloader. Create a client with new Courier\Client(apiKey: getenv('COURIER_API_KEY')) and send with $client->send->message(message: [...]). The package was renamed: the older trycourier/courier-php name now redirects. It requires PHP 8.1 or newer because it uses named arguments, and its README currently flags v5 as beta.Keep exploring
One API, every channel
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.