SDKs & framework transports
The official PHP SDK (pufferpost/sdk) gives you a typed client for the API: construct it with an API key, send single or batch emails, pass an idempotency key, and catch a clean exception tree. If you already use Symfony Mailer (or Laravel, which runs on it), the companion pufferpost/symfony-mailer package is a drop-in transport: register a DSN and your existing $mailer->send($email) flows through the API. Both are MIT-licensed.
Everything here talks to the same HTTP API documented in the API reference: base URL https://pufferpost.com/api/v1, bearer API-key auth. See Authentication for how to mint a key_live_… key.
PHP SDK
Section titled “PHP SDK”Install
Section titled “Install”composer require pufferpost/sdkConstruct the client
Section titled “Construct the client”The only required argument is the API key. The base URL defaults to https://pufferpost.com; override it (and inject a custom HTTP client for tests) only when you need to.
use PufferPost\Sdk\Client;
$client = new Client('key_live_…');
// Override base URL / inject a test HttpClient when needed:// new Client('key_test_…', 'https://pufferpost.com', $mockHttpClient);Send a single email
Section titled “Send a single email”Sending is template-based: reference a template by its stable id (templateId, the tpl_… id), and pass data to render it. The convenience factory covers the common case.
use PufferPost\Sdk\Client;use PufferPost\Sdk\Email;
$client = new Client('key_live_…');
$result = $client->send(Email::fromTemplateId( from: 'no-reply@acme.com', to: 'jane@example.com', templateId: 'tpl_…', data: ['name' => 'Jane'],));
echo $result->id; // msg_…echo $result->status; // acceptedsend() returns a PufferPost\Sdk\SendResult with two read-only properties: id (the new msg_… id) and status (initially accepted; the actual delivery is async).
Email exposes every field the API accepts as constructor arguments, so you can build a fully-specified message directly instead of using the factory:
use PufferPost\Sdk\Attachment;use PufferPost\Sdk\Email;
$email = new Email( from: 'no-reply@acme.com', to: 'jane@example.com', templateId: 'tpl_…', // the template's stable tpl_ id data: ['order' => '1234', 'eta' => '2026-06-12'], metadata: ['order_id' => '1234'], // opaque key/values, echoed back on the message replyTo: 'support@acme.com', cc: ['ops@acme.com'], unsubscribeGroup: 'grp_receipts', locale: 'nl', // per-send locale / timezone timezone: 'Europe/Amsterdam', bcc: ['audit@acme.com'], headers: ['X-Order' => '1234'], // X- names only, capped and allow-listed by the API attachments: [Attachment::fromContents('receipt.pdf', 'application/pdf', $pdfBytes)],);
$client->send($email);Send without a template
Section titled “Send without a template”A send is either templated or inline, never both. Email::inline() delivers a subject and body
exactly as given: the inline body is never run through the template engine, so {{ variables }}
in it mean nothing.
use PufferPost\Sdk\Email;
$client->send(Email::inline( from: 'no-reply@acme.com', to: 'jane@example.com', subject: 'Your receipt', html: '<h1>Thanks!</h1>',));Pass text on the constructor alongside html for a plain-text alternative part, or on its own for
a text-only message. A stored template is still the better default: it is versioned, previewable and
editable without a deploy. See Sending email for the field-by-field rules.
Attachments
Section titled “Attachments”Attachment::fromContents() takes raw bytes and base64-encodes them for you; the constructor takes
content you have already encoded.
use PufferPost\Sdk\Attachment;
Attachment::fromContents('invoice.pdf', 'application/pdf', file_get_contents('/tmp/invoice.pdf'));new Attachment('invoice.pdf', 'application/pdf', $alreadyBase64);The API caps both the number of attachments and the total message size; an oversized send is
rejected with a ValidationException.
Idempotency
Section titled “Idempotency”Pass an idempotency key as the second argument to send() so a retry after a network blip never sends twice. The SDK forwards it as the Idempotency-Key header; the API replays the original result for a repeated key.
$client->send($email, idempotencyKey: 'order-1234-welcome');See Errors & idempotency for the full semantics.
Send a batch
Section titled “Send a batch”sendBatch() submits up to 100 emails in one POST /api/v1/messages/batch call. Each item is accepted or rejected independently: the batch as a whole still succeeds even when some items fail.
Batch takes no idempotency key (the endpoint does not accept one), so a retried batch re-accepts every message in it. Where a duplicate would matter, send those messages with send() and a key each.
use PufferPost\Sdk\Client;use PufferPost\Sdk\Email;
$client = new Client('key_live_…');
$batch = $client->sendBatch([ Email::fromTemplateId('no-reply@acme.com', 'jane@example.com', 'tpl_…', ['name' => 'Jane']), Email::fromTemplateId('no-reply@acme.com', 'bob@example.com', 'tpl_…', ['name' => 'Bob']),]);
foreach ($batch->results as $item) { if ($item->accepted) { printf("#%d accepted: %s\n", $item->index, $item->id); } else { printf("#%d rejected: %s (%s)\n", $item->index, $item->errorCode, $item->errorMessage); }}sendBatch() returns a PufferPost\Sdk\BatchResult whose results is a list of BatchItemResult, in submission order. Each BatchItemResult carries index, accepted (bool), and either id (when accepted) or errorCode + errorMessage (when rejected).
Retrieve a message
Section titled “Retrieve a message”$message = $client->getMessage('msg_…');
echo $message->status; // accepted | delivered | bounced | …echo $message->to;echo $message->createdAt; // ISO-8601 stringgetMessage() returns a PufferPost\Sdk\Message with read-only properties id, status, to, from, fromName (nullable, the display name the recipient saw), subject (nullable), tags (list of strings), createdAt, and updatedAt.
The exception tree
Section titled “The exception tree”Every non-2xx response is mapped to a typed exception that carries the stable error code, the HTTP statusCode, and the requestId (when present) so you can correlate with server logs. The hierarchy lets you catch broadly or narrowly:
MailerException (\RuntimeException, base for everything the SDK throws)├── ApiException (any non-2xx; statusCode, errorCode, requestId)│ ├── AuthenticationException (401, bad/revoked key)│ ├── ValidationException (422, invalid payload, e.g. recipient_suppressed)│ └── RateLimitException (429, back off and retry)└── TransportException (network failure: DNS / TLS / timeout)use PufferPost\Sdk\Exception\ApiException;use PufferPost\Sdk\Exception\AuthenticationException;use PufferPost\Sdk\Exception\RateLimitException;use PufferPost\Sdk\Exception\TransportException;use PufferPost\Sdk\Exception\ValidationException;
try { $client->send($email);} catch (AuthenticationException $e) { // 401: rotate or fix the API key} catch (RateLimitException $e) { // 429: back off, then retry} catch (ValidationException $e) { echo $e->errorCode; // e.g. recipient_suppressed} catch (ApiException $e) { // any other non-2xx (4xx/5xx) error_log($e->getMessage().' ('.$e->requestId.')');} catch (TransportException $e) { // never reached the API at all}Catch ApiException to handle every HTTP error in one place, or MailerException to also swallow transport failures. See Errors & idempotency for the full list of stable code values.
Symfony Mailer transport
Section titled “Symfony Mailer transport”pufferpost/symfony-mailer is a drop-in Symfony Mailer transport built on the SDK. Because Laravel 9+ runs on Symfony Mailer, the same package serves Laravel too.
Install
Section titled “Install”composer require pufferpost/symfony-mailerConfigure the DSN
Section titled “Configure the DSN”Point your PufferPost DSN at the pufferpost+api scheme, with the API key as the user part and default as the host:
MAILER_DSN=pufferpost+api://key_live_…@default
# Optional: target another host# MAILER_DSN=pufferpost+api://key_live_…@default?base_url=https://pufferpost.comBoth pufferpost://… and pufferpost+api://… schemes are accepted. The ApiTransportFactory is autowired as a Symfony TransportFactoryInterface, so no extra wiring is needed.
Send an ordinary email (drop-in)
Section titled “Send an ordinary email (drop-in)”A plain $mailer->send($email) with a subject and an HTML or text body is delivered as an inline
send, so the transport is a drop-in for the mail your app already builds locally:
use Symfony\Component\Mime\Email;use Symfony\Component\Mailer\MailerInterface;
public function receipt(MailerInterface $mailer): void{ $email = (new Email()) ->from('no-reply@acme.com') ->to('jane@example.com') ->subject('Your receipt') ->html('<h1>Thanks!</h1>');
$mailer->send($email); // → POST /api/v1/messages}Send a stored template
Section titled “Send a stored template”To have the API render a stored template instead, use the provided MailerEmail, which carries the
API-specific fields as allow-listed X-Mailer-* headers so you never hand-write them:
use PufferPost\Symfony\MailerEmail;use Symfony\Component\Mailer\MailerInterface;
public function welcome(MailerInterface $mailer): void{ $email = (new MailerEmail()) ->from('no-reply@acme.com') ->to('jane@example.com') ->templateId('tpl_…') // the template's stable tpl_ id ->templateData(['name' => 'Jane']) ->metadata(['plan' => 'pro']) ->idempotencyKey('order-1234');
$mailer->send($email); // → POST /api/v1/messages}With a template id set, the template version owns the subject and the body: any subject or body part on the Symfony email is left out of the request.
MailerEmail extends Symfony’s Email, so all the usual builder methods (from, to, cc, bcc, replyTo, subject, html, text, attachFromPath, …) are available. It adds seven of its own:
| Method | Header | What it does |
|---|---|---|
templateId(string) |
X-Mailer-Template-Id |
Render this stored template (tpl_…) server-side. |
templateData(array) |
X-Mailer-Data |
The render variables for that template. |
metadata(array) |
X-Mailer-Metadata |
Opaque key/values, echoed back on reads and webhooks. |
idempotencyKey(string) |
X-Mailer-Idempotency-Key |
Makes a retry of this send safe. |
unsubscribeGroup(string) |
X-Mailer-Unsubscribe-Group |
Send under a subscription topic. |
locale(string) |
X-Mailer-Locale |
Pick the per-locale template variant. |
timezone(string) |
X-Mailer-Timezone |
Render dates in the recipient’s zone. |
Attachments, cc, bcc and replyTo are taken straight off the Symfony email in both shapes, so
->attachFromPath('/tmp/invoice.pdf') needs nothing extra. Display names survive the trip: new Address('no-reply@acme.com', 'Acme Padel Club') is sent as written, on from, to, cc, bcc and replyTo.
The transport sends one POST /api/v1/messages per To recipient. When an email with an idempotency key has several of them, the key is suffixed with the recipient address so each send keeps its own replay protection.
Laravel
Section titled “Laravel”Laravel 9+ sends mail through Symfony Mailer, so the same transport serves it. Laravel resolves
mailers from config/mail.php rather than from a MAILER_DSN, so register the transport once in a
service provider’s boot():
use Illuminate\Support\Facades\Mail;use PufferPost\Symfony\ApiTransportFactory;use Symfony\Component\Mailer\Transport\Dsn;
public function boot(): void{ Mail::extend('pufferpost', function (array $config) { return (new ApiTransportFactory())->create( new Dsn('pufferpost+api', 'default', $config['key'], options: [ 'base_url' => $config['base_url'] ?? 'https://pufferpost.com', ]), ); });}'mailers' => [ 'pufferpost' => [ 'transport' => 'pufferpost', 'key' => env('PUFFERPOST_KEY'), 'base_url' => env('PUFFERPOST_BASE_URL', 'https://pufferpost.com'), ],],MAIL_MAILER=pufferpostPUFFERPOST_KEY=key_live_…Ordinary Laravel mail then flows through the API: Mail::to('jane@example.com')->send(new OrderShipped($order)) sends a Mailable’s rendered view as an inline send. For a stored template plus data, build a MailerEmail as above and hand it to the mailer, or call the SDK directly.
Gotchas
Section titled “Gotchas”- A send needs a template id, or a subject and a body. An email with neither is rejected with a
Symfony\Component\Mailer\Exception\TransportExceptionbefore any HTTP call. (The raw SDK enforces the same rule server-side: anEmailcarriestemplateId, orsubjectplushtml/text.) - Batch sending is SDK-only. The Symfony transport posts one message per recipient; use
PufferPost\Sdk\Client::sendBatch()for fan-out. - The SDK covers sending and reading a message.
send(),sendBatch()andgetMessage(). Everything else the API offers (templates, domains, senders, suppressions, webhooks, contacts, workflow triggers) is a plain HTTP call against the API reference. status: acceptedis notdelivered. Sending is asynchronous. PollgetMessage()or subscribe to webhooks for the real outcome.- A template id wins over the local subject and body. Set
->templateId(...)and the template version supplies both; the Symfony subject and body parts are then left out of the request entirely. Drop the template id and the same email sends inline, exactly as you built it. - The SDK base URL has no
/api/v1suffix. It’shttps://pufferpost.com, and each method appends the versioned path (e.g./api/v1/messages). Only overridebase_urlto point at a different host.