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://app.pufferpost.com/v1, bearer API-key auth. See Authentication for how to mint a key_live_… key.
PHP SDK
Install
composer require pufferpost/sdkConstruct the client
The only required argument is the API key. The base URL defaults to https://app.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://app.pufferpost.com', $mockHttpClient);Send a single email
Sending is template-based: reference a template by slug (template) or by its stable id (templateId), and pass data to render it. The convenience factories cover the common case.
use PufferPost\Sdk\Client;use PufferPost\Sdk\Email;
$client = new Client('key_live_…');
$result = $client->send(Email::fromTemplate( from: 'no-reply@acme.com', to: 'jane@example.com', template: 'welcome', 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\Email;
$email = new Email( from: 'no-reply@acme.com', to: 'jane@example.com', template: 'order-shipped', // or: templateId: 'tpl_…' 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',);
$client->send($email);Use Email::fromTemplateId(...) if you prefer to pin the stable tpl_… id rather than the slug.
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
sendBatch() submits up to 100 emails in one POST /v1/messages/batch call. Each item is accepted or rejected independently — the batch as a whole still succeeds even when some items fail.
use PufferPost\Sdk\Client;use PufferPost\Sdk\Email;
$client = new Client('key_live_…');
$batch = $client->sendBatch([ Email::fromTemplate('no-reply@acme.com', 'jane@example.com', 'welcome', ['name' => 'Jane']), Email::fromTemplate('no-reply@acme.com', 'bob@example.com', 'welcome', ['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
$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, subject (nullable), tags (list of strings), createdAt, and updatedAt.
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
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
composer require pufferpost/symfony-mailerConfigure 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://app.pufferpost.comBoth pufferpost://… and pufferpost+api://… schemes are accepted. The ApiTransportFactory is autowired as a Symfony TransportFactoryInterface — no extra wiring needed.
Send through $mailer
The API renders the body server-side from a template + data, so the transport carries those as allow-listed X-Mailer-* headers. Use the provided MailerEmail so you never hand-write headers — plain $mailer->send($email) keeps working:
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') ->subject('Welcome') // shown in clients; the template owns the rendered body ->text('Welcome!') // Symfony requires a body part to be set ->template('welcome') ->templateData(['name' => 'Jane']) ->metadata(['plan' => 'pro']) ->idempotencyKey('order-1234');
$mailer->send($email); // → POST /v1/messages}MailerEmail extends Symfony’s Email, so all the usual builder methods (from, to, cc, subject, text, …) are available; it adds template(), templateData(), metadata(), and idempotencyKey(). The transport sends one POST /v1/messages per envelope recipient.
Laravel
Laravel 9+ uses Symfony Mailer under the hood, so the same transport applies. Use the DSN form directly:
pufferpost+api://key_live_…@defaultThe MailerEmail builder (template, data, idempotency key) works identically once the transport is registered.
Gotchas
- The transport is template-only. A plain Symfony
Emailwith noX-Mailer-Templateheader is rejected with aSymfony\Component\Mailer\Exception\TransportExceptionbefore any HTTP call — always set->template(...). (The raw SDK enforces the same: anEmailneedstemplateortemplateId.) - Batch sends and workflow triggers are SDK-only. The Symfony transport sends one message at a time; use
PufferPost\Sdk\Client::sendBatch()for fan-out. status: acceptedis notdelivered. Sending is asynchronous. PollgetMessage()or subscribe to webhooks for the real outcome.subject/textare local-only in the transport. The rendered body comes from the server-side template; the Symfony body parts just satisfy PufferPost’s requirement that a message has content.- The SDK base URL has no
/v1suffix — it’shttps://app.pufferpost.com, and each method appends the versioned path (e.g./v1/messages). Only overridebase_urlto point at a different host.