wildbit/postmark-php
wildbit/postmark-php is the official PHP client for Postmark, making it easy to send transactional and bulk email through Postmark’s API. It supports templates, attachments, batch sending, bounce and message APIs, with simple Composer installation and Laravel integration via a companion adapter.
Install the package (official Postmark SDK, MIT, actively released through 2025):
composer require wildbit/postmark-php
Get a server token from Postmark → Server → API Tokens. This is different from the account token used for provisioning (see PostmarkAdminClient below). Put it in config/services.php as services.postmark.token and POSTMARK_TOKEN in .env.
Instantiate Postmark\PostmarkClient and send your first message:
use Postmark\PostmarkClient;
$client = new PostmarkClient(config('services.postmark.token'));
$response = $client->sendEmail(
from: '[email protected]',
to: '[email protected]',
subject: 'Your receipt',
htmlBody: view('emails.receipt', $data)->render(),
textBody: '...',
tag: 'receipt',
messageStream: 'outbound',
);
$response->MessageID; // use this for support lookups
Where to look first
src/Postmark/PostmarkClient.php — every server-level call (sendEmail, sendEmailBatch, sendEmailWithTemplate, getBounces, getOutboundMessages, getInboundMessages, …).src/Postmark/PostmarkAdminClient.php — account-level: servers, domains, sender signatures, DKIM/SPF verification.src/Postmark/Models/ — response shapes (PostmarkResponse, PostmarkBounce, PostmarkOutboundMessage, …).src/Postmark/PostmarkException.php — the only exception you need to catch for API errors.Recommended first real use case: transactional sends (receipts, password resets). Once that works, layer templates + tags + MessageStream for broadcast, then bounces/message-search for support tooling.
Laravel note: the framework ships a
postmarkmail driver that wraps this same SDK. Use the driver for plainMail::send, but reach for the SDK directly when you need bounces, message search, templates, inbound parsing, or admin/provisioning calls.
Templated sends (recommended for anything user-facing). Store templates in Postmark, call by alias so you can promote changes without redeploying:
$client->sendEmailWithTemplate(
from: '[email protected]',
to: $user->email,
templateIdOrAlias: 'welcome-email',
templateModel: ['first_name' => $user->first_name, 'cta_url' => $url],
tag: 'welcome',
messageStream: 'outbound',
);
Batch sends. Build an array of payloads and send up to 500 per call. This is the right tool for digests, notifications, and backfills — it is dramatically faster than looping sendEmail:
$messages = $users->map(fn ($u) => [
'From' => '[email protected]',
'To' => $u->email,
'TemplateAlias' => 'weekly-digest',
'TemplateModel' => ['name' => $u->first_name, 'items' => $u->digest],
'Tag' => 'digest',
'MessageStream' => 'broadcast',
])->all();
foreach (array_chunk($messages, 500) as $chunk) {
$results = $client->sendEmailBatch($chunk);
foreach ($results as $i => $r) {
if (($r->ErrorCode ?? 0) !== 0) {
Log::warning('postmark batch item failed', ['index' => $i, 'error' => $r->ErrorCode, 'msg' => $r->Message]);
}
}
}
Attachments. Always go through PostmarkAttachment — it base64-encodes for the API. Do not pre-encode:
use Postmark\Models\PostmarkAttachment;
$msg = [
'From' => '...', 'To' => '...',
'Subject' => 'Invoice', 'TextBody' => '...',
'Attachments' => [
PostmarkAttachment::fromFile(storage_path("invoices/{$id}.pdf"), "invoice-{$id}.pdf", 'application/pdf'),
// or:
PostmarkAttachment::fromRawData($bytes, 'notes.txt', 'text/plain'),
],
];
$client->sendEmail(...$msg); // or sendEmailBatch([$msg])
Error handling wrapper. Centralize retries in a small service used across jobs:
use Postmark\PostmarkException;
use GuzzleHttp\Exception\ConnectException;
try {
return $client->sendEmail(...$payload);
} catch (PostmarkException $e) {
// 4xx → don't retry (bad address, unverified From, template error)
// 429 or 5xx → retry with backoff
if ($e->httpStatusCode === 429 || $e->httpStatusCode >= 500) {
throw new \RuntimeException('postmark retryable', 0, $e);
}
report($e);
} catch (ConnectException $e) {
// network/timeout — retry
throw $e;
}
Bounce and suppression workflow. Run a scheduled job that pulls recent bounces and mirrors them into your own suppression list, then activateBounce() when an address is fixed:
$bounces = $client->getBounces(count: 500, offset: 0, type: 'HardBounce', inactive: false);
Support lookups. getOutboundMessages() with tag, recipient, fromDate/toDate, or messageID filters is your first stop for "did the customer get the email?". Pair with getOutboundMessageDetails() for SMTP dialogue and getMessageOpens()/getMessageClicks() for engagement.
Admin / provisioning. Use PostmarkAdminClient with the account token for domains, DKIM rotation, sender signatures. Cache the responses — you rarely need to hit this at runtime:
$admin = new \Postmark\PostmarkAdminClient(config('services.postmark.account_token'));
$admin->getDomains();
Inbound email. Handle Postmark's inbound webhook in a controller, return 200 fast, and queue processing:
$payload = json_decode($request->getContent(), true);
ProcessInboundEmail::dispatch($payload); // From, Subject, TextBody, HtmlBody, Attachments (base64)
return response()->noContent();
Testing. The client accepts a Guzzle client as the second constructor argument — inject a MockHandler (or a handler that returns canned Postmark JSON) so unit tests never hit the API. Avoid Http::fake(); it won't catch Guzzle traffic from this package.
$mock = new \GuzzleHttp\Handler\MockHandler([
new \GuzzleHttp\Psr7\Response(200, [], json_encode(['MessageID' => 'abc', 'ErrorCode' => 0])),
]);
$client = new PostmarkClient('dummy', new \GuzzleHttp\Client(['handler' => $mock]));
Message streams. Explicitly pass MessageStream (outbound for transactional, broadcast for bulk). Set the correct default at the Postmark server level too — mixing streams will trigger rate-limit/broadcast compliance behavior you probably don't want.
$throw_errors defaults to true. The third constructor arg (new PostmarkClient($token, $client, false)) makes failures return error payloads instead of throwing. Don't mix modes across a codebase — silently swallowed errors become "why didn't the email send?" tickets.ErrorCode, not just HTTP status, on batch responses. Batch calls return 200 even when individual items are rejected (invalid To, missing template variable, unverified sender). Iterate every item.composer why-not wildbit/postmark-php before upgrading. This is the most common install-time surprise.From must match a verified sender signature or an authenticated domain, otherwise 422 with errorCode: 300. Common in staging where the domain isn't verified.PostmarkAttachment::fromFile() / fromRawData() only. Pre-base64-encoding produces corrupt PDFs. Total message size limit is 10 MB.sleep(60) inside a request — queue the job.PostmarkException. Timeouts and DNS failures surface as GuzzleHttp\Exception\ConnectException. Catch both, or you'll get unhandled exceptions that bypass your error-code logic.timeout on the Guzzle client you pass in, especially in queue workers and cron.PostmarkAdminClient uses the account token (different scope). Passing the server token yields 401.422 and blocks the send. Keep a smoke test per template.TrackOpens / TrackLinks values are case-sensitive (HtmlAndText, HtmlOnly, TextOnly, None) and are gated by server-level settings.Postmark\Models\* objects. If you're upgrading across a major, audit ['MessageID'] vs ->MessageID access in your code.From for authorization.How can I help you explore Laravel packages today?