posthastemail / laravel-mailer
Laravel mail driver for the Posthaste transactional email API.
Requires
- php: ^8.2
- ext-curl: *
- ext-json: *
- illuminate/contracts: ^12.0|^13.0
- illuminate/mail: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- psr/log: ^1.0|^2.0|^3.0
- symfony/mailer: ^7.0|^8.0
- symfony/mime: ^7.0|^8.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
This package is not auto-updated.
Last update: 2026-08-28 06:19:00 UTC
README
A Laravel mail driver for the Posthaste transactional email API.
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.
composer require posthastemail/laravel-mailer
Requires PHP 8.2+ and Laravel 12 or 13.
The whole change
- MAIL_MAILER=smtp - MAIL_HOST=smtp.mailgun.org - MAIL_PORT=587 - MAIL_USERNAME=postmaster@… - MAIL_PASSWORD=… - MAIL_ENCRYPTION=tls + MAIL_MAILER=posthaste + POSTHASTE_API_KEY=ph_live_… MAIL_FROM_ADDRESS=billing@acme.example MAIL_FROM_NAME="Acme"
Nothing to add to config/mail.php. The service provider registers the posthaste mailer for
you, which is the difference between a one-line migration and a three-file one — a stock Laravel
app'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.
Auto-discovery handles the provider. There is nothing to register.
The MAIL_FROM_ADDRESS must be on one of your
verified domains — exactly as it must be for a direct API
send, and the most common reason a first send is refused.
Mail::to('customer@example.com')->send(new InvoiceIssued($invoice));
When to use this instead of the SMTP relay
Posthaste also runs an SMTP relay, and Laravel's built-in
smtp mailer can point at it today with no new package at all. That is often the simpler answer.
Reach for this driver when:
- Outbound SMTP is blocked or expensive where you run. Most serverless and container platforms either block port 25/587 outright or make a long-lived connection costly. An HTTPS request has neither problem.
- You want the fields SMTP has nowhere to put — tags, metadata, message streams, an explicit idempotency key, or scheduled sending.
- 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 Name <addr> for the sender. |
->to(), ->cc(), ->bcc() |
Every form. See the display-name caveat below. |
->replyTo() |
Display names kept, and several addresses kept: 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() / <img src="cid:…"> |
Inline parts keep a Content-ID the HTML resolves against. See below. |
Custom headers via Mailables\Headers |
Anything not on the platform's reserved list. |
->tag() and ->metadata() on a Mailable |
Straight onto the API's own tags and metadata — no Posthaste-specific code in your app. |
->priority() |
The same X-Priority header Laravel already writes. |
Mail::fake(), MessageSending / MessageSent |
Untouched — this is a real Symfony Transport, so the whole framework works around it. |
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
would become a broken-image icon, with a green test suite.
Mapped, with something changed
Each of these is logged at warning level on the send that produces it, and carried on the
MessageAccepted event.
| Laravel | What happens | Why |
|---|---|---|
A display name on to / cc / bcc |
Name dropped, address unchanged. | The API addresses recipients by bare address, as does the platform's own SMTP door. Refusing would break the migration for nearly every app in existence. |
attachData($bytes) with no name |
Sent as attachment-1. |
The API requires a filename, and a failed send over a field you never set is worse. |
A from 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 Posthaste relay for raw messages. |
Two from addresses |
Two From addresses is a different identity from one, and picking yours would change who the recipient sees the mail as being 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 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 to a different set of people than you asked for. |
| 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-*. |
| 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
here. They belong to the API, which refuses with the limit and the measured value attached; this
driver surfaces that as an AttachmentException carrying getLimit() and getFilename().
List-Unsubscribe is refused as a header because it is a first-class API field: set it and the
platform pairs it with List-Unsubscribe-Post and signs both.
Posthaste-only fields
Three things the API offers have nowhere to live in a Laravel 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 and X-PM-Message-Stream. 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 the single most useful line here: 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 retries a send at all.
A default stream, tags and metadata can be set once for the whole application, and a message
always wins over them:
POSTHASTE_STREAM=transactional
Stored templates are deliberately not wired up: Blade is your template engine, and the stored-template feature exists for senders that have none. Call the API directly if you want one.
Errors
Every failure is a Posthaste\Laravel\Exceptions\PosthasteException, which extends Symfony's
TransportException — so Laravel's mail stack, your queue worker's failed-job handling and every
catch (TransportExceptionInterface $e) you already have 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. |
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. | The data. Never retry. |
ContentBlockedException |
The pre-send lint refused the body. | The message. getFindings() is the whole report. |
AttachmentException |
Too many, too large, a blocked type. | The attachment. getLimit() says what to fit in. |
MessageRefusedException |
Unknown stream or template, schedule too far, validation. | The request. getFields() says which field. |
RateLimitedException |
Too fast, or the platform is briefly holding the queue. | 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, timeout. | Retry, carefully. See below. |
UnsupportedMessageException |
This 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.
The idiomatic handling from a queued job:
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. $this->user->update(['mailable' => false, 'unmailable_reason' => $e->getReason()]); } 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; // pre-send lint findings that did not withhold it });
The Posthaste message id is also on the SentMessage that Mail::send() returns, which is what
every webhook, log line and waybill is keyed by:
$sent = Mail::to('customer@example.com')->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, Retry-After
honoured up to 60 seconds. Quota exhaustion is never waited out in-process however long the header
says, because that wait is measured in hours and a blocked PHP worker is not the right place to spend
them.
POSTHASTE_RETRIES=2 POSTHASTE_MAX_RETRY_DELAY=60 POSTHASTE_TIMEOUT=30
Self-hosting
Point it at your own deployment:
POSTHASTE_BASE_URL=https://mail.internal.acme.example
Everything else is identical. To route the HTTP itself through your own client — Guzzle, a corporate proxy, a tracing decorator — bind the one-method interface:
$this->app->bind(\Posthaste\Laravel\Http\HttpTransport::class, MyHttpTransport::class);
The default implementation is ext-curl and nothing else: Laravel only suggests Guzzle, and a mail
driver that needs one POST has no business adding a dependency to applications that chose not to have
one.
Your API key
Whoever holds the key can send mail from every verified domain on the account, so this package treats it as a credential rather than as a string:
- It is never a property on any object, so
dd($mailer),print_r(),var_export()and Ignition's exception page have nothing to print. (__debugInfo()alone is not enough — Symfony's VarDumper merges it with the real properties rather than replacing them.) - It is redacted from every exception message, including one where the server echoed the key back in a badly written 401, and from anything cURL quotes.
(string) $transportisposthaste://api.posthastemail.devand never a DSN with credentials in it, because Symfony puts that string in its own exception messages.- Redirects are never followed, because cURL re-sends the
Authorizationheader to whatever host theLocationnames.
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 image.
Tests
composer install vendor/bin/phpunit
The suite never opens a socket and never sends mail, and both are enforced rather than trusted: the
stub transport refuses a request for any host outside the RFC 2606 reserved set and any message
addressed outside example.com, the base URL under test is a .invalid domain that can never
resolve, and a source scan fails the build if any file names an address at a domain we do not own.
Licence
MIT.