easymailing / sdk-php
Official Easymailing SDK for PHP backends.
Requires
- php: ^8.1
- ext-json: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.50
- phpstan/phpstan: ^1.10
- phpunit/phpunit: ^10.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.0 || ^2.0
- psr/log: ^2.0 || ^3.0
Suggests
- psr/http-client: Required to use Psr18Transport (Guzzle, Symfony HttpClient, etc.)
- psr/http-factory: Required to use Psr18Transport (PSR-17 request/stream factories)
- psr/log: Required to use Psr3EventListener (bridges SDK telemetry events to any PSR-3 logger)
This package is auto-updated.
Last update: 2026-05-28 19:35:35 UTC
README
Official Easymailing SDK for PHP backends.
Alpha. Public API may still change before
1.0.0. Pin to a known version (e.g."easymailing/sdk-php": "v0.6.0-alpha.0") in production.
📦 This is a read-only mirror. The SDK lives in the
easymailing/easymailing-sdkmonorepo underpackages/php/. Submit issues, pull requests, and discussions upstream — every commit and tag here is auto-generated by a subtree split workflow. Packagist watches this mirror because it does not readcomposer.jsonfrom monorepo subdirectories.
Backend only
This SDK authenticates with a secret API key or OAuth access token. Do not use it in client-rendered code.
Requirements
- PHP 8.1+
ext-jsonext-curl(default transport — drop inPsr18TransportorWordPressTransportif you can't have cURL)
Install
composer require easymailing/sdk-php:^0.6.0-alpha
Quick start
use Easymailing\Sdk\Easymailing; $em = new Easymailing(apiKey: getenv('EASYMAILING_API_KEY')); // Generated resources — methods return typed DTO objects $page = $em->audiences->list(['page' => 1]); foreach ($page->data as $audience) { echo $audience->iri, ' — ', $audience->title, "\n"; } $campaign = $em->campaigns->createRevalidation($body); $members = $em->members->search(email: 'sergio@example.com'); $forms = $em->audiences('audience-uuid')->subscriptionForms->list(); $order = $em->stores('store-uuid')->orders->import($body); $variants = $em->stores('store-uuid')->products('product-uuid')->variants->list();
Generated resources
Most API endpoints are exposed as generated resources on the client. The full
reference lives in docs/resources.md.
Common call shapes:
// Top-level collection $em->audiences->list(['page' => 1]); $em->audiences->create($body); $em->audiences->get('audience-uuid'); // Nested resources $em->audiences('audience-uuid')->members->list(['status' => 'suscriber.status.confirmed']); $em->audiences('audience-uuid')->subscriptionForms->list(); // Actions and custom operations $em->campaigns->createRevalidation($body); $em->members->search(email: 'sergio@example.com'); $em->stores('store-uuid')->orders->import($body); $em->stores('store-uuid')->orders->refund('order-resource-id', $body); // Deep nested resources $em->stores('store-uuid')->products('product-resource-id')->variants->list();
Request bodies can be plain arrays or generated DTOs. Entity methods return
generated DTOs; collection methods return Page<DTO>.
Auth
Pass exactly one of apiKey or accessToken. The constructor throws on both/neither and on empty strings.
new Easymailing(apiKey: '...'); // sends X-Auth-Token new Easymailing(accessToken: '...'); // sends Authorization: Bearer
Hydra identity: iri + uuid
Every generated DTO carries ?string $iri (the Hydra @id verbatim) and
?string $uuid (the trailing UUID segment) as first-class typed properties.
The @id, @type and @context keys are stripped — they're transport
metadata, not domain fields.
$audience = $em->audiences->get('AUD-UUID'); echo $audience->iri; // "/audiences/AUD-UUID" echo $audience->uuid; // "AUD-UUID" echo $audience->title; // domain field
If you only have an IRI (e.g. from a webhook payload), use IriExtractor to
peel off the trailing segment:
use Easymailing\Sdk\Hydra\IriExtractor; IriExtractor::extract('/audiences/AUD-UUID/members/MEM-UUID'); // "MEM-UUID"
Transports
Three transports ship in the box:
CurlTransport(default) — zero deps, requiresext-curl.Psr18Transport— adapter for any PSR-18 client (Guzzle, Symfony HttpClient withPsr18Client, Buzz, etc.).WordPressTransport— wrapswp_remote_requestso the SDK works inside a WP plugin without bundling Guzzle.
use Easymailing\Sdk\Transport\WordPressTransport; $em = new Easymailing( apiKey: '...', transport: new WordPressTransport(timeoutSeconds: 30), );
Bodies are JSON-encoded automatically and the SDK sets
Content-Type: application/json on POST/PUT/PATCH/DELETE-with-body. If you pass
your own Content-Type header (any casing) it wins — useful for
application/merge-patch+json and similar.
Errors
All API errors derive from EasymailingException and follow RFC 7807:
AuthException(401/403)NotFoundException(404)ValidationException(422) — exposes$violations(Symfony format)RateLimitException(429) — exposes$retryAfterSecondsServerException(5xx)NetworkException(transport-level: DNS, timeout, etc.)MalformedResponseException(server returned non-JSON or wrong shape)
The client retries idempotent requests, 429s, and 503s with exponential
backoff. Configurable via the maxRetries constructor arg.
Telemetry events
Plug in your own observability without the SDK depending on a specific logger.
The client emits structured SdkEvent instances for every request lifecycle
stage, batch poll, and webhook signature check through a single onEvent
callable.
use Easymailing\Sdk\Easymailing; use Easymailing\Sdk\Telemetry\SdkEvent; use Easymailing\Sdk\Telemetry\EventTypes; $em = new Easymailing( apiKey: '...', onEvent: function (SdkEvent $event): void { match ($event->type) { EventTypes::REQUEST_END => $myLogger->log([ 'requestId' => $event->requestId, 'pathTemplate' => $event->pathTemplate, // "/audiences/{audienceUuid}/members/{uuid}" — metric-safe 'status' => $event->payload['status'], 'durationMs' => $event->payload['durationMs'], 'error' => $event->payload['error'] ?? null, // 422 violations preserved ]), EventTypes::WEBHOOK_REJECTED => $siem->alert($event->payload), default => null, }; }, );
Event types
| Type constant | When |
|---|---|
EventTypes::REQUEST_START |
Before the request leaves the SDK. |
EventTypes::REQUEST_RETRY |
The SDK is about to retry (429, 5xx, network). |
EventTypes::REQUEST_END |
Request finished. status + optional error + durationMs. |
EventTypes::BATCH_POLLING |
Each poll iteration in batch->wait(). Carries snapshot progress. |
EventTypes::BATCH_FINISHED |
Batch reached finished. |
EventTypes::BATCH_TIMEOUT |
wait() ran past maxWaitMs — BatchTimeoutException is thrown. |
EventTypes::WEBHOOK_VERIFIED |
webhooks->verify() accepted the signature. |
EventTypes::WEBHOOK_REJECTED |
webhooks->verify() rejected (signature-mismatch, invalid-format, invalid-secret). |
Each SdkEvent carries type, v: 1 (schema version), timestampMs,
optional requestId (16-char hex — same value across start / retry / end
for one logical request), method, path, pathTemplate, and a payload
array specific to the event type.
PSR-3 adapter
If you already use a PSR-3 logger (Monolog, Symfony, Laravel, etc.), the SDK
ships a Psr3EventListener that maps event types to log levels with a
sensible default (5xx → error, retry/4xx/timeout/webhook-rejected → warning,
everything else → debug):
use Easymailing\Sdk\Easymailing; use Easymailing\Sdk\Telemetry\Psr3EventListener; $em = new Easymailing( apiKey: '...', onEvent: new Psr3EventListener($monolog), );
The adapter requires psr/log — install it explicitly if your project doesn't
already pull it in (composer require psr/log).
Safety, correlation, cardinality
requestIdcorrelates one full request lifecycle acrossstart,retry,end.pathTemplate(/audiences/{audienceUuid}/members/{uuid}) is metric-safe — no UUID cardinality blow-up in Prometheus / Datadog.pathcarries the real substituted URL.- Safety: a throwing
onEventhandler can never break the API call — the SDK wraps every invocation in try/catch and logs toerror_logonly. - No secrets: the SDK never emits auth headers, request body, or webhook
secrets. 422
violationsare kept because UIs need them.
Batch
The /batch_operations endpoint is asynchronous: you submit up to 500
operations, the server processes them in the background, and the SDK polls
until done. The whole flow can take seconds to many minutes depending on size
and rate limits.
Two flavours
run() — blocking, for CLI / workers / long-lived processes:
use Easymailing\Sdk\Batch\BatchTypes\BatchOperation; $result = $em->batch->run([ new BatchOperation( method: 'POST', path: '/audiences/AUD-UUID/members', body: ['email' => 'a@b.c'], // SDK serializes arrays automatically externalIdentifier: 'import-1', ), ]); echo $result->snapshot->status; // "finished" echo count($result->responses ?? []); // number of operations echo $result->errors?->totalErrors ?? 0; // null if no errors
Polling uses exponential backoff (1s → 2s → 4s → ... cap 30s) with jitter.
Default maxWaitMs is 30 minutes; on timeout, BatchTimeoutException is
thrown but the batch keeps running server-side and the exception carries the
UUID so a worker can resume.
runAsync() — fire-and-forget, for HTTP / Symfony controllers / FPM:
// In your controller — returns in one round-trip: $snapshots = $em->batch->runAsync($operations); $this->db->saveJob(['batch_uuid' => $snapshots[0]->uuid, 'status' => 'pending']); return new JsonResponse(['job_id' => $snapshots[0]->uuid], 202); // Later, in a Symfony Messenger consumer or worker process: $em->batch->wait($uuid); $responses = $em->batch->fetchResponsesGuaranteed($uuid);
Why
fetchResponsesGuaranteed()and not plainfetchResponses()? The API writes the results file asynchronously after the status flips tofinished, and auto-deletes it 1 hour later.fetchResponsesGuaranteedcalls the regenerate endpoint when the file isn't there — covering both the post-finish race window and old batches whose file already expired. Use the barefetchResponses($snapshot)only when you already know the snapshot has a freshresponse_body_url.
Why two methods? PHP-FPM has max_execution_time (typically 30–60 s).
Calling run() from a controller will deadlock against that timeout for any
non-trivial batch. Use runAsync() there and wait() from a worker.
Low-level primitives
create, get, wait, fetchResponses, errors, regenerateResponseBodyUrl
are all exposed for custom flows. The presigned response_body_url expires
after 15 minutes — use regenerateResponseBodyUrl($uuid) to get a fresh
one if you need to download the file again.
No batch.finished webhook
The API does not currently emit a webhook event when a batch finishes. You
must poll (or subscribe to the SDK's batch.finished telemetry event if your
own process did the wait).
Webhooks
$em = new Easymailing(apiKey: '...'); if ($em->webhooks->verify($rawBody, $signature, $secret)) { $event = $em->webhooks->parse($rawBody); error_log($event['event_type']); }
verify() uses hash_hmac + hash_equals (constant-time). Signature must
start with sha256= followed by hex. Both verified and rejected outcomes are
emitted as webhook.verified / webhook.rejected telemetry events.
parse() returns an array{event_type: string, webhook_id?: string, data: mixed}
(a discriminated array shape, not an object — keeps the wire payload faithful).
Typed event constants
The WebhookEvents class exposes one public const per known event_type.
Compare against it instead of hand-writing string literals:
use Easymailing\Sdk\Generated\Webhooks\WebhookEvents; $event = $em->webhooks->parse($rawBody); match ($event['event_type']) { WebhookEvents::MEMBER_SUBSCRIBED => handleSubscribed($event['data']), WebhookEvents::MEMBER_CAMPAIGN_BOUNCED => handleBounce($event['data']), default => null, // unknown event types still arrive };
WebhookEvents::all() returns the full list. The catalogue is generated from
the upstream WebhookEventType PHP enum (composer generate:webhooks).
$event['data'] stays loosely typed (mixed) for now; a follow-up plan will
tighten it once the upstream payload DTOs are catalogued.
Changelog
See CHANGELOG.md. Releases ship via
release-please: a feat: or
fix: commit in the monorepo opens a Version PR; merging it tags the
monorepo, the split workflow propagates the tag to this mirror, and Packagist
auto-publishes via webhook.
License
MIT — see LICENSE.