Sending
Laravel mail driver
If your application already sends mail with Mail::, the whole migration is one line of .env. Every Mailable, notification, queued job and Mail::to(...)->send() you have stays exactly as it is.
The whole change
composer require posthastemail/laravel-mailerMAIL_MAILER=smtp
MAIL_HOST=smtp.mailgun.org
MAIL_PORT=587
[email protected]
MAIL_PASSWORD=…
MAIL_ENCRYPTION=tls
[email protected]
MAIL_FROM_NAME="Acme"There is nothing to add to config/mail.php and no provider to register. The package registers the posthaste mailer itself, which is the difference between a one-line migration and a three-file one: a Laravel application’s config/mail.php is published into the app, so a package cannot get an entry into it, and a driver that skips this step makes you hand-edit an array before its own README works.
Requires PHP 8.2 or newer, and Laravel 12 or 13. MAIL_FROM_ADDRESS must be on one of your verified domains — exactly as it must be for a direct API send, and the single most common reason a first message is refused.
// Unchanged. Every Mailable, notification and queued job you have
// already written keeps working exactly as it is.
Mail::to('[email protected]')->send(new InvoiceIssued($invoice));When to use this instead of the SMTP relay
Posthaste already runs an SMTP relay, and Laravel’s built-in smtp mailer can point at it today with no new package at all. That is a perfectly good answer, and for an ordinary server it is often the simpler one — so here is the honest comparison rather than a pitch.
| Reach for | When |
|---|---|
| SMTP relay | You are on a normal server or container, port 587 is open outbound, and you send ordinary mail. Nothing to install, and it works from any framework rather than only this one. |
| This driver | Outbound SMTP is blocked or throttled where you run. Most serverless and container platforms either block port 25/587 outright or make a long-lived connection expensive; an HTTPS request has neither problem. |
| This driver | You want the fields SMTP has nowhere to put: tags and metadata, message streams, an explicit idempotency key, or scheduled sending. |
| This driver | You want to branch on why a message was refused. SMTP gives you a three-digit code and a sentence; this gives you an exception class per remedy, with the evidence behind the refusal on it. |
The caveat runs the other way too. The relay takes a complete RFC 5322 message, so anything Laravel can compose it can carry. This driver sends fields, and the tables below are what that costs.
What maps, what changes, what is refused
The governing rule is that nothing is dropped in silence. A team that changes one config line, watches their tests pass and only learns months later that their Bcc stopped arriving is the outcome this design exists to prevent. Anything that cannot be honoured is a synchronous exception naming the field, never a quiet omission.
Mapped, unchanged
| Laravel | Notes |
|---|---|
->from(), MAIL_FROM_ADDRESS | Display name kept — the API parses a named sender. |
->to(), ->cc(), ->bcc() | Every form. See the display-name caveat below. |
->replyTo() | Display names kept, and several addresses kept: Reply-To is an address list, and the API writes the string into the header verbatim. |
| Subject, text body, HTML body | Blade-rendered exactly as they are today. |
->attach(), ->attachData(), Attachable | Filename, content type and bytes. |
->embed() and cid: references | Inline parts keep a Content-ID the HTML resolves against. See below. |
->tag() and ->metadata() | Straight onto the API’s own tags and metadata — no Posthaste-specific code in your application at all. |
| Custom headers, priority | Anything not on the platform’s reserved list. |
Mail::fake(), MessageSending, MessageSent | Untouched. This is a real Symfony Transport, so the whole framework works around it — including queued mailables and your existing test suite. |
Embedded images are worth a sentence. $message->embed($path) hands your Blade template the string cid:<basename>, and Symfony normally rewrites that into the part’s real Content-ID while composing the MIME. This driver never composes MIME — the platform does, server-side, so it can sign it — so the driver performs that rewrite itself. Without it, every embedded image in an application becomes a broken-image icon, with a green test suite.
Mapped, with something changed
Each of these is written to your application log at warning level on the send that produces it, and carried on the MessageAccepted event — so you hear about it on the first send rather than from a customer.
| Laravel | What happens | Why |
|---|---|---|
| A display name on a recipient | Name dropped, address unchanged. | The API addresses recipients by bare address, as does the platform’s own SMTP door. Refusing instead would break the migration for nearly every application that has ever written ->to($user->email, $user->name). |
attachData() with no name | Sent as attachment-1. | The API requires a filename, and a failed send over a field you never knew existed is worse than a name you can change. |
| A sender display name over 320 characters | Name dropped, address kept. | The API’s limit. The part that decides delivery is untouched. |
Refused
These throw before anything is sent, and each names what to do instead.
| Laravel | Why |
|---|---|
A pre-composed RawMessage | The JSON API takes fields and rebuilds the MIME so it can DKIM-sign and align it. Point a plain smtp mailer at the relay for raw messages. |
Two from addresses | Two From addresses is a different identity from one, and picking one of yours would change who the recipient sees the mail as coming from. |
->sender() | Delivery-controlling, and a value that disagrees with the signed From is a DMARC failure. |
->returnPath() | Posthaste writes its own — a VERP token, which is how a bounce is attributed back to one message and how the suppression list stays accurate. |
An explicit Symfony Envelope that differs from the headers | It changes who is actually delivered to. Ignoring it would deliver the message to a different set of people than you asked for, which is the worst thing a mail driver can do quietly. |
| A reserved header | From, To, Cc, Bcc, Sender, Subject, Date, Message-ID, MIME-Version, Content-*, Return-Path, DKIM-Signature, Received, Authentication-Results, List-Unsubscribe, Feedback-ID, ARC-*. All written and signed by the platform. |
| The same header name twice | The API carries one value per name, and there is no correct way to collapse two into one. |
| A header value containing a line break | It would end the header and start one of the sender’s choosing. |
Attachment limits — count, total size, blocked executable types — are deliberately not duplicated in the package. They belong to the API, which refuses with the limit and the measured value attached, and the driver surfaces that on the exception rather than making you find it in a changelog.
The fields Laravel has no word for
Three things the API offers have nowhere to live in a Mailable’s Envelope, so they are set the way this corner of the ecosystem has always set them — an X- control header, the same idea as X-SES-Configuration-Set. Each is consumed by the driver, and none of them reaches the recipient.
use Illuminate\Mail\Mailables\Headers;
public function headers(): Headers
{
return new Headers(text: [
'X-Posthaste-Stream' => 'transactional',
'X-Posthaste-Idempotency-Key' => "invoice-{$this->invoice->id}",
'X-Posthaste-Schedule-At' => '2026-09-01T09:00:00Z',
]);
}Set the idempotency key on anything that matters. It is what makes a retry after a lost response a replay rather than a second invoice in somebody’s inbox — and it is the only condition under which this driver repeats a send at all.
Tags and metadata need no control header, because Laravel already has words for them and they map one-for-one:
use Illuminate\Mail\Mailables\Envelope;
public function envelope(): Envelope
{
return new Envelope(
subject: 'Invoice '.$this->invoice->reference,
tags: ['invoice'],
metadata: ['invoiceId' => (string) $this->invoice->id],
);
}A default stream, tags and metadata can be set once for the whole application with POSTHASTE_STREAM, and a message always wins over them. Stored templates are deliberately not wired up: Blade is your template engine, and that feature exists for senders which have none.
Errors that name a remedy
Every failure is a PosthasteException, which extends Symfony’s TransportException — so Laravel’s mail stack, your queue worker’s failed-job handling and every catch you already have around another driver keep working unchanged. Under it there is a real hierarchy, because “mail failed” is not a fix.
| Exception | What went wrong | The fix |
|---|---|---|
AuthenticationException | No key, wrong key, revoked key, missing emails:send, suspended account. | The credential. Never retry. |
DomainNotVerifiedException | MAIL_FROM_ADDRESS is not on a domain you have proved you own. | Publish the DKIM record. Never retry. |
SuppressedRecipientException | Every recipient is on the suppression list. | Stop mailing them. Never retry. |
InvalidRecipientException | An address is not usable as an address. | The data. Never retry. |
ContentBlockedException | The pre-send lint refused the body. | The message. getFindings() is the whole report, warnings included, so one editing pass covers everything. |
AttachmentException | Too many, too large, a blocked type. | The attachment. getLimit() says what to fit inside. |
MessageRefusedException | Unknown stream or template, schedule too far out, validation. | The request. getFields() says which field. |
RateLimitedException | Too fast, or the platform is briefly holding the queue back. | Wait getRetryAfterSeconds() — measured in seconds. |
QuotaExhaustedException | Daily warmup cap or monthly allowance spent. | Wait hours, or upgrade. |
ServerException | Our fault. | Retry. |
ConnectionException | Nothing answered — DNS, TLS, a timeout. | Retry, carefully: the request may have arrived and only the answer been lost. |
UnsupportedMessageException | The driver refused the message before sending it. | The message. getField() names it. |
RateLimitedException and QuotaExhaustedException are siblings, never parent and child. Both arrive as HTTP 429 with a Retry-After, and the status alone cannot tell them apart — which is exactly the trap that makes a naive retry loop hammer a wall for the rest of the month. No catch should ever get one while meaning the other.
use Posthaste\Laravel\Exceptions\PosthasteException;
use Posthaste\Laravel\Exceptions\RateLimitedException;
use Posthaste\Laravel\Exceptions\SuppressedRecipientException;
public function handle(): void
{
try {
Mail::to($this->user)->send(new InvoiceIssued($this->invoice));
} catch (SuppressedRecipientException $e) {
// Permanent, and the fix lives in your database rather than in a retry.
$this->user->update([
'mailable' => false,
'unmailable_reason' => $e->getReason(), // 'complaint'
]);
} catch (RateLimitedException $e) {
// Seconds. Put the job back rather than sleeping a worker.
$this->release($e->getRetryAfterSeconds() ?? 60);
} catch (PosthasteException $e) {
report($e);
$e->isTransient() ? $this->release(300) : $this->fail($e);
}
}Suppressed recipients on a fan-out
Note the asymmetry. When only some recipients of a multi-recipient message are suppressed, the send succeeds — the mail went to everybody else — so nothing throws. Those recipients are logged, and carried on an event, because an application that is never told has silently stopped mailing somebody.
use Posthaste\Laravel\Events\MessageAccepted;
Event::listen(function (MessageAccepted $event) {
foreach ($event->result->suppressedAddresses() as $address) {
Subscriber::where('email', $address)->update(['mailable' => false]);
}
$event->result->id; // 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
$event->result->duplicate; // true when an idempotency key replayed
$event->result->warnings; // lint findings that did not withhold the message
});The Posthaste message id is also on the SentMessage that Mail::send() returns. It is not an RFC 5322 Message-ID — that one is composed and signed server-side at delivery — but it is the id every webhook, log line and waybill is keyed by, so it is the one worth storing.
$sent = Mail::to('[email protected]')->send(new InvoiceIssued($invoice));
$sent->getMessageId(); // 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'Retries
A send is repeated in-process only when it carries an idempotency key. Without one, a retry after a lost response sends the mail twice, and a customer receiving two invoices is worse than a customer receiving an error.
When it does retry: two attempts by default, exponential backoff with full jitter, and Retry-After honoured up to sixty seconds. Quota exhaustion is never waited out in-process however long the header says — that wait is measured in hours, and a blocked PHP worker is not the right place to spend them. It comes back as an exception so a queued job can release itself instead.
Self-hosting, and your own HTTP client
POSTHASTE_BASE_URL=https://mail.internal.acme.exampleEverything else is identical. The package’s only HTTP dependency is ext-curl — Laravel merely suggests Guzzle, and a mail driver that needs one POST has no business adding a dependency to applications that chose not to have one. To route the request through your own client, a corporate proxy or a tracing decorator, bind the one-method HttpTransport interface in a service provider.
Your API key
Whoever holds the key can send mail from every verified domain on the account, so the package treats it as a credential rather than as a string. It is never a property on any object, so dd($mailer), print_r() and an exception page have nothing to print; it is redacted from every exception message, including one where a badly written 401 echoed the key back; (string) $transport is a host and never a DSN with credentials in it; and redirects are never followed, because cURL would re-send the Authorization header to whatever host the Location names.
Keep the key in .env. Never write it into a published config/posthaste.php: that file is committed, and php artisan config:cache bakes it into an artefact that ships inside your deployment image.
NextSend an email →Every field on POST /v1/emails, including replyTo and List-Unsubscribe.