hampel / sparkpost
A PHP client for the SparkPost API - transmissions, message events and suppression - over any PSR-18 HTTP client
Requires
- php: >=8.3
- ext-json: *
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1|^2.0
- psr/log: ^1.1|^2.0|^3.0
Requires (Dev)
- guzzlehttp/guzzle: ^7.8|^8.0
- hampel/rig: ^0.1.2
- laravel/pint: ^1.30
- phpstan/phpstan: ^2.1.22
- phpunit/phpunit: ^12.0
This package is auto-updated.
Last update: 2026-08-22 09:27:49 UTC
README
By Simon Hampel
A PHP client for the SparkPost API, built on PSR-18 so it works with whatever HTTP client your application already has.
Installation
composer require hampel/sparkpost
You also need a PSR-18 client and a PSR-17 factory. Guzzle provides both, 7 or 8:
composer require guzzlehttp/guzzle
Usage
use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Hampel\SparkPost\Config; use Hampel\SparkPost\SparkPost; use Hampel\SparkPost\Transmission\Transmission; $guzzle = new Client(); $factory = new HttpFactory(); // PSR-17, fills both the request and stream roles $sparkpost = new SparkPost(new Config('MY-API-KEY'), $guzzle, $factory, $factory); $result = $sparkpost->transmissions()->send( Transmission::make() ->from('webmaster@example.com', 'Webmaster') ->subject('Hello') ->text('Hello from SparkPost.') ->to('me@example.com', 'Me') );
For the EU tenancy, or any other region:
$sparkpost = new SparkPost(Config::forRegion('MY-API-KEY', 'eu'), $guzzle, $factory, $factory);
A PSR-3 logger is optional and takes a fifth argument. Requests are logged at debug,
failures at error; attachment payloads are truncated before they reach the log.
Building a transmission
send() takes a Transmission, or a plain array if you would rather build the payload
yourself.
use Hampel\SparkPost\Transmission\Attachment; use Hampel\SparkPost\Transmission\Transmission; $transmission = Transmission::make() ->from('webmaster@example.com', 'Webmaster') ->subject('Your invoice') ->html('<p>Attached. Also see <img src="cid:logo"></p>') ->text('Attached.') ->to('alice@example.com', 'Alice') ->cc('accounts@example.com', 'Accounts') ->bcc('archive@example.com') ->replyTo('billing@example.com') ->header('X-Campaign', 'invoices') ->attach(Attachment::fromPath('/tmp/invoice.pdf', 'invoice.pdf', 'application/pdf')) ->attach(Attachment::inline('logo', 'image/png', $logoBytes)) ->transactional() ->openTracking(false) ->campaignId('invoices') ->metadata(['user_id' => 7]) ->substitutionData(['first_name' => 'Alice']);
Most of that is obvious. These parts are not, and are the reason the builder exists:
- SparkPost sends one message per recipient, so without a
header_toevery recipient sees aTo:line containing only themselves. The builder sets it on every recipient from yourto()list, which is what reproduces ordinary mail. - Cc is made visible by a
CCheader, not by the recipient list — the recipients are how the mail is addressed, the header is how it is displayed. Bcc gets no header, which is what makes it blind. - A dozen headers are rejected if you pass them in
content.headers, because SparkPost derives them from the transmission itself.header()drops those rather than letting the API reject the whole send. falsesurvives. An option set tofalseis sent asfalse, not dropped as empty —openTracking(false)means "do not track opens", not "use the account default".- Mail from
@sparkpostbox.comswitches thesandboxoption on by itself, because that domain silently fails without it.sandbox(false)overrides.
Stored templates and A/B tests replace the content entirely:
Transmission::make()->to('alice@example.com')->template('welcome'); Transmission::make()->to('alice@example.com')->abTest('subject-line');
Message events
use Hampel\SparkPost\MessageEvent\EventQuery; use Hampel\SparkPost\MessageEvent\EventType; $query = EventQuery::make() ->events(EventType::Bounce, EventType::SpamComplaint, EventType::ListUnsubscribe) ->from(new DateTimeImmutable('-1 day')) ->to(new DateTimeImmutable()) ->perPage(100); foreach ($sparkpost->messageEvents()->each($query) as $event) { // one event at a time, across as many pages as it takes }
each() is lazy — a page is only fetched once the previous one has been consumed, so
stopping early stops making requests. from and to are converted to UTC, which is what
the API assumes when no timezone is given.
Picking the work up again later
There are usually more events than one request has time to collect, so the position in a search is a cursor that casts to a string. Store it wherever a string can go, and resume in the next run:
$page = $sparkpost->messageEvents()->search($query); process($page->results); if ($page->hasMore()) { $job->data['cursor'] = (string) $page->next(); // and stop here }
// ... a request, a job, or an hour later $page = $sparkpost->messageEvents()->next(EventCursor::fromString($job->data['cursor']));
A page reports $page->totalCount, counts, and iterates. Events themselves are plain
arrays: their shape varies a great deal by event type, and pinning it down would mean
either a type that hides most of the payload or twenty of them.
Bounce classes
SparkPost reports why a message bounced as a numeric class. What to do about each one is your policy, but the codes and what they mean are SparkPost's:
use Hampel\SparkPost\MessageEvent\BounceClass; // note the cast: SparkPost sends bounce_class as a string, and may add codes later $class = BounceClass::tryFrom((int) ($event['bounce_class'] ?? 0)); $class?->classification(); // Hard, Soft, Block, Admin, Undetermined $class?->classification()->isPermanent(); // whether to stop sending to this address $class?->slug(); // 'invalid_recipient'
Suppression
Two questions, and the API answers both awkwardly enough to be worth wrapping.
$suppression = $sparkpost->suppression(); if ($suppression->isSuppressed('someone@example.com')) { // SparkPost is dropping mail to this address } $entry = $suppression->find('someone@example.com'); $entry?->source; // 'Bounce Rule', 'Spam Complaint', 'Manually Added', ... $entry?->description; // the remote server's own words: "550-5.1.1 The email account ..." $entry?->created; // DateTimeImmutable $entry?->transactional;
find() returns null for an address that is not suppressed, which is worth stating
because the API does not: SparkPost answers 404, so "this address is fine" arrives as an
error. Only the 404 is translated — a 401 from a bad key is still thrown, rather than
quietly reporting every address as clear.
Removing an entry lets SparkPost attempt delivery again:
$suppression->delete('someone@example.com'); // false if it was not on the list
That is the operation worth having. SparkPost suppresses on a hard bounce by itself, and its list and your own idea of who may be emailed then drift apart — so an address you have re-enabled at your end can still be silently dropped at SparkPost's. Nothing in the sending path reports it, because it is not an error.
Reading the whole list, a page at a time:
foreach ($suppression->each() as $entry) { // lazy - stop early and it stops making requests } $page = $suppression->search(page: 1, perPage: 100); $page->totalCount; $page->hasMore;
The list you see is the one your API key can see. Suppression is scoped per subaccount,
and a subaccount key is bound to its own automatically — so an address suppressed under a
different subaccount simply is not there as far as that key is concerned. To work with a
particular subaccount's list, use a key belonging to it. sendingDomains()->forAddress()
above is how to find out which subaccount an address you send from belongs to.
Adding one is a PUT, and therefore an upsert — an address already listed has its entry
replaced rather than the call being rejected:
$suppression->add('someone@example.com', 'asked us to stop'); $suppression->add('someone@example.com', transactional: false);
The list is eventually consistent, which is worth knowing before you write anything
around it. Measured against the live API: an added address took about six seconds to become
readable, and a deleted one stayed readable about as long after the delete succeeded. add()
returning true means SparkPost accepted the write, not that isSuppressed() agrees yet.
Nothing here retries for you — poll if you need to see the change.
Whether an unsubscribe in your application should also go on SparkPost's list is a policy question, and it is yours rather than this package's.
Sending domains, and finding the subaccount
Read-only. Creating and verifying a sending domain is an administration task done once in SparkPost's own UI, and a key that can write here can change where an account's mail appears to come from — so this only reads, and the key only needs the read grant.
foreach ($sparkpost->sendingDomains()->all() as $domain) { $domain->domain; $domain->subaccountId; $domain->isDefaultBounceDomain; $domain->status['ownership_verified'] ?? null; }
The reason it is here is subaccountId. Suppression is per-subaccount, and a subaccount
API key cannot read the subaccounts endpoint at all — SparkPost offers no such permission
for one. The sending domain carries the number, so the address you send as is the way to
find out which subaccount you are operating in:
$domain = $sparkpost->sendingDomains()->forAddress('Support <noreply@mail.example.com>'); $domain?->subaccountId; // 3629 $domain?->hasSubaccount(); // false for the primary account, which is 0 or absent
forAddress() reads the display-name form as well as a bare address, and returns null
without making a request when it is handed something that is not an address at all.
find($domain) returns null for a domain the account does not have — which is also the
reason a send from it would be refused.
HTTP 200 does not mean the mail was sent
SparkPost returns 200 having accepted zero recipients, so the status code alone will tell
you a send succeeded when nothing left the building. That is what TransmissionResult is
for:
if (! $result->wasAccepted()) { // nothing was sent, whatever the status code said } $result->id; // the transmission id $result->totalAcceptedRecipients; $result->totalRejectedRecipients; $result->hasRejections();
Rejected recipients are reported rather than thrown, because what counts as a failure is
your policy: a mail transport should treat wasAccepted() === false as a failed send, a
bulk job may not.
Errors
Everything this package throws implements Hampel\SparkPost\Exception\ExceptionInterface,
so one clause catches the lot. Below that, the distinctions are the ones you would actually
branch on:
| Exception | When | Retry? |
|---|---|---|
RequestException |
The request never reached SparkPost — DNS, TLS, connect timeout | Yes, and it cannot have duplicated anything |
ClientException |
A 4xx. The request or the key was wrong | Not unchanged |
RateLimitException |
A 429, with $retryAfter when the header was sent |
Yes, after waiting |
ServerException |
A 5xx. SparkPost's problem, probably temporary | Yes |
InvalidArgumentException |
Caught before the network — empty key, unencodable payload | No |
ClientException, RateLimitException and ServerException all carry $statusCode,
the decoded $errors array, the raw $body, and $retryAfter.
use Hampel\SparkPost\Exception\RateLimitException; use Hampel\SparkPost\Exception\ExceptionInterface; try { $sparkpost->transmissions()->send($transmission); } catch (RateLimitException $e) { $queue->release($e->retryAfter ?? 60); } catch (ExceptionInterface $e) { $log->error($e->getMessage()); }
Bringing your own HTTP client
Any PSR-18 client works, which matters when the host application has its own HTTP stack
that you are not free to bypass — one routing every outbound request through a configurable
proxy, with SSRF protections applied on the way out. An adapter implementing sendRequest()
over that keeps all of it and still shares this package, rather than forcing a second API
client to be written alongside.
The same seam is what makes the test suite network-free — see tests/StubClient.php.
Endpoints not yet wrapped
$sparkpost->connection() exposes get() and post() directly, so an endpoint this
package has not covered yet is a call away rather than a release away:
$body = $sparkpost->connection()->get('webhooks');
Paths may be given bare (transmissions), with a leading slash, or exactly as SparkPost
returns them in pagination links (/api/v1/events/message?page=2) — the version prefix is
handled either way.
Licence
MIT. See LICENSE.md.