easymailing/sdk-php

Official Easymailing SDK for PHP backends.

Maintainers

Package info

github.com/easymailing/sdk-php

Homepage

Issues

pkg:composer/easymailing/sdk-php

Statistics

Installs: 13

Dependents: 0

Suggesters: 0

Stars: 0

v0.6.1-alpha.0 2026-05-28 19:35 UTC

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-sdk monorepo under packages/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 read composer.json from 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-json
  • ext-curl (default transport — drop in Psr18Transport or WordPressTransport if 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, requires ext-curl.
  • Psr18Transport — adapter for any PSR-18 client (Guzzle, Symfony HttpClient with Psr18Client, Buzz, etc.).
  • WordPressTransport — wraps wp_remote_request so 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 $retryAfterSeconds
  • ServerException (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

  • requestId correlates one full request lifecycle across start, retry, end.
  • pathTemplate (/audiences/{audienceUuid}/members/{uuid}) is metric-safe — no UUID cardinality blow-up in Prometheus / Datadog. path carries the real substituted URL.
  • Safety: a throwing onEvent handler can never break the API call — the SDK wraps every invocation in try/catch and logs to error_log only.
  • No secrets: the SDK never emits auth headers, request body, or webhook secrets. 422 violations are 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 plain fetchResponses()? The API writes the results file asynchronously after the status flips to finished, and auto-deletes it 1 hour later. fetchResponsesGuaranteed calls 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 bare fetchResponses($snapshot) only when you already know the snapshot has a fresh response_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.