Skip to content

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.

Terminal window
composer require pufferpost/sdk

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);

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; // accepted

send() 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);

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.

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.

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.

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).

$message = $client->getMessage('msg_…');
echo $message->status; // accepted | delivered | bounced | …
echo $message->to;
echo $message->createdAt; // ISO-8601 string

getMessage() 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.

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.

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.

Terminal window
composer require pufferpost/symfony-mailer

Point your PufferPost DSN at the pufferpost+api scheme, with the API key as the user part and default as the host:

.env
MAILER_DSN=pufferpost+api://key_live_…@default
# Optional: target another host
# MAILER_DSN=pufferpost+api://key_live_…@default?base_url=https://pufferpost.com

Both pufferpost://… and pufferpost+api://… schemes are accepted. The ApiTransportFactory is autowired as a Symfony TransportFactoryInterface, so no extra wiring is needed.

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
}

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 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',
]),
);
});
}
config/mail.php
'mailers' => [
'pufferpost' => [
'transport' => 'pufferpost',
'key' => env('PUFFERPOST_KEY'),
'base_url' => env('PUFFERPOST_BASE_URL', 'https://pufferpost.com'),
],
],
.env
MAIL_MAILER=pufferpost
PUFFERPOST_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.

  • A send needs a template id, or a subject and a body. An email with neither is rejected with a Symfony\Component\Mailer\Exception\TransportException before any HTTP call. (The raw SDK enforces the same rule server-side: an Email carries templateId, or subject plus html/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() and getMessage(). Everything else the API offers (templates, domains, senders, suppressions, webhooks, contacts, workflow triggers) is a plain HTTP call against the API reference.
  • status: accepted is not delivered. Sending is asynchronous. Poll getMessage() 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/v1 suffix. It’s https://pufferpost.com, and each method appends the versioned path (e.g. /api/v1/messages). Only override base_url to point at a different host.