elasticemail/elasticemail-php
ElasticEmail PHP SDK for managing email via the REST API: send campaigns, transactional email, contacts, and templates using a generated Guzzle-based client with API key auth. Requires PHP 7.4+.
Install the Package
composer require elasticemail/elasticemail-php:^4.4
The 4.4 release is a drop-in for send calls that only pass a payload — no code changes required.
Configure API Key
// config/services.php
'elasticemail' => [
'api_key' => env('ELASTICEMAIL_API_KEY'),
],
ELASTICEMAIL_API_KEY=your_key_here
Or configure the shared client directly:
$config = ElasticEmail\Configuration::getDefaultConfiguration()
->setApiKey('X-ElasticEmail-ApiKey', config('services.elasticemail.api_key'));
First Use Case: Send a Transactional Email (retry-safe)
use Illuminate\Support\Str;
$apiInstance = new ElasticEmail\Api\EmailsApi(new GuzzleHttp\Client(), $config);
// One key per logical send. Reuse it ONLY to retry this exact send.
$idempotencyKey = (string) Str::uuid();
$body = new \ElasticEmail\Model\SendTransactionalEmailRequest([
'from' => '[email protected]',
'to' => '[email protected]',
'subject' => 'Test Email',
'bodyHtml' => '<h1>Hello!</h1>',
]);
$result = $apiInstance->emailsTransactionalPost($body, $idempotencyKey);
If the call times out or the connection drops, call it again with the same $body object and the same key. You get the original response back (same TransactionID/MessageID, header Idempotency-Replayed: true) and no second email is sent or billed.
examples/: Pre-built use cases (e.g., sendTransactionalEmails.php).src/Api/EmailsApi.php: The three idempotency-aware send methods (emailsTransactionalPost, emailsPost, emailsMergefilePost) plus their ...WithHttpInfo, ...Async, ...AsyncWithHttpInfo and ...Request variants.src/Model/: Request/response models (e.g., SendTransactionalEmailRequest).Wrap the client so the key is threaded through one place:
// app/Services/ElasticEmailService.php
class ElasticEmailService
{
protected ElasticEmail\Api\EmailsApi $emails;
public function __construct()
{
$config = ElasticEmail\Configuration::getDefaultConfiguration()
->setApiKey('X-ElasticEmail-ApiKey', config('services.elasticemail.api_key'));
$this->emails = new ElasticEmail\Api\EmailsApi(new GuzzleHttp\Client(), $config);
}
/**
* @param string $idempotencyKey Stable per logical send; reused across retries.
*/
public function sendTransactional($message, string $idempotencyKey)
{
return $this->emails->emailsTransactionalPost($message, $idempotencyKey);
}
public function send($message, string $idempotencyKey)
{
return $this->emails->emailsPost($message, $idempotencyKey);
}
public function sendMergeFile($payload, string $idempotencyKey)
{
return $this->emails->emailsMergefilePost($payload, $idempotencyKey);
}
}
// AppServiceProvider::register()
$this->app->singleton(ElasticEmailService::class, fn () => new ElasticEmailService());
Make the key a required argument in your wrapper. An optional key is the one people forget on the retry path — which is exactly when it matters.
The key must survive process death, queue restarts and tries exhaustion handling. Put it on the record you're sending:
// database/migrations/..._create_email_sends_table.php
$table->uuid('idempotency_key')->unique();
$table->string('status')->default('pending');
$table->string('provider_message_id')->nullable();
// app/Models/EmailSend.php
protected static function booted(): void
{
static::creating(fn (self $m) => $m->idempotency_key ??= (string) Str::uuid());
}
$send = EmailSend::create(['to' => $to, 'subject' => $subject, 'payload' => $payload]);
SendTransactionalEmail::dispatch($send->id);
Store the built payload (or the model it's derived from) too, so the retry produces a byte-identical body. Generating 'sentAt' => now() inside the job on each retry will change the body and give you a 422.
Because Laravel serializes the job's properties, the key travels with the payload automatically — retries are idempotent for free:
class SendTransactionalEmail implements ShouldQueue
{
public int $tries = 5;
public array $backoff = [5, 15, 60, 180];
public function __construct(public int $emailSendId) {}
public function handle(ElasticEmailService $mail): void
{
$send = EmailSend::findOrFail($this->emailSendId);
try {
$result = $mail->sendTransactional($send->payload, $send->idempotency_key);
$send->update(['status' => 'sent', 'provider_message_id' => $result->getMessageID()]);
} catch (\ElasticEmail\ApiException $e) {
$status = $e->getCode();
// 409: first request still running. 503: key not yet confirmed. Retry shortly.
if (in_array($status, [409, 503], true)) {
$this->release($this->retryAfter($e));
return;
}
// 422: same key, different body/endpoint — a bug, not a transient error.
if ($status === 422) {
report($e);
$send->update(['status' => 'failed']);
return;
}
throw $e;
}
}
protected function retryAfter(\ElasticEmail\ApiException $e): int
{
$headers = $e->getResponseHeaders();
return max(1, (int) ($headers['Retry-After'][0] ?? 5));
}
}
Note that a 4xx or 5xx on the first attempt releases the key, so a corrected retry with the same key runs as a fresh send — you don't need to regenerate the key after a validation fix.
$payload = new \ElasticEmail\Model\MergeEmailPayload([
'file' => new \GuzzleHttp\Psr7\Stream(fopen(storage_path('app/contacts.csv'), 'r')),
'subject' => 'Bulk Campaign',
'bodyHtml' => '<p>Hello {name}!</p>',
]);
$result = $emails->emailsMergefilePost($payload, $send->idempotency_key);
Bulk and merge sends are the highest-cost operations, so idempotency pays for itself fastest here. Cache the file contents and pass a fresh Stream on each retry — but keep the underlying bytes identical.
...WithHttpInfo returns [$data, $statusCode, $headers]; use it to measure how often dedupe actually saves you:
[$data, $status, $headers] = $emails->emailsTransactionalPostWithHttpInfo($message, $key);
if (($headers['Idempotency-Replayed'][0] ?? null) === 'true') {
Log::info('ElasticEmail replayed stored response', ['message_id' => $data->getMessageID()]);
}
Use webhooks (via the ElasticEmail dashboard) to drive Laravel jobs; they are unaffected by this release:
Route::post('/elasticemail/webhook', [EmailWebhookHandler::class, 'handle']);
class EmailWebhookHandler
{
public function handle(Request $request)
{
$event = $request->json()->all();
if ($event['type'] === 'email_sent') {
EmailSentJob::dispatch($event['data']);
}
}
}
Reconcile the webhook's MessageID against your stored provider_message_id — with idempotency enabled, a replayed response returns the original ID, so any duplicate send attempt is detectable as "same ID, two attempts".
// app/Exceptions/Handler.php
public function render($request, Throwable $exception)
{
if ($exception instanceof \ElasticEmail\ApiException) {
return response()->json([
'error' => 'ElasticEmail API Error',
'details' => $exception->getResponseBody(),
], 400);
}
return parent::render($request, $exception);
}
$contentTypeThe signature is now emailsTransactionalPost($data, $idempotency_key = null, $contentType = null). Any positional $contentType must be shifted:
// Before 4.4: worked, set Content-Type
$api->emailsTransactionalPost($message, 'application/json');
// After 4.4: 'application/json' is now sent as the Idempotency-Key!
// Every send shares one key -> the second send with a different body returns 422.
Fix by passing null for the key, or use a named argument:
$api->emailsTransactionalPost($message, null, 'application/json');
$api->emailsTransactionalPost($message, contentType: 'application/json');
Grep your codebase for emailsTransactionalPost(, emailsPost( and emailsMergefilePost( with a second positional string argument before upgrading. The same applies to the ...WithHttpInfo, ...Async, ...AsyncWithHttpInfo and ...Request variants.
// WRONG: new key on each attempt = no protection at all
foreach ($attempts as $attempt) { $api->emailsTransactionalPost($msg, (string) Str::uuid()); }
// RIGHT
$key = (string) Str::uuid();
for ($i = 0; $i < 3; $i++) {
try { $api->emailsTransactionalPost($msg, $key); break; }
catch (\ElasticEmail\ApiException $e) { if (! in_array($e->getCode(), [409, 503])) throw $e; sleep(2); }
}
Keys are matched on endpoint + body bytes. These all turn a replay into a 422:
'sentAt' => now() or Str::random() re-evaluated inside the job./emails vs /emails/transactional).The 24-hour window means a delayed queue retry (or a retryUntil past a day) silently becomes a new send. Keep your retry window comfortably under 24 hours and alert if a job is older than the key.
| Status | Meaning | Action |
|---|---|---|
409 |
First request still running with that key | Retry with Retry-After, same key, same body |
422 |
Key reused with different body/endpoint | Bug — do not retry blindly; log and inspect |
400 |
Invalid key (empty, non-ASCII, >255 chars) | Fix key generation |
503 |
Key couldn't be confirmed | Retry with same key after Retry-After seconds |
Laravel's $this->release($delay) and Guzzle's retry middleware handle 409/503 well; neither should be used to blindly retry 422.
idempotency_key columns accordingly./emails/transactional, /emails, /emails/mergefile. Campaign sends, contact imports and exports are not idempotent — keep polling-based reconciliation for those.Client-side validation accepts 1–255 printable ASCII. Str::uuid() (36 chars) and Str::random(32) are both fine. Avoid ULIDs is fine too, but anything containing control characters or non-ASCII (e.g. a raw binary hash, or a key prefixed with an emoji) fails with 400. Prefix keys for observability if you like: "send:{$uuid}" is still valid.
Concurrency limit is 20; per-request timeout is 600s. For bulk:
foreach ($contacts as $contact) {
SendEmailJob::dispatch($contact->id);
}
For long-running exports, poll status — this path has no idempotency protection, so make the poller tolerant of duplicates:
$exportId = $contactApi->contactsExportPost($criteria);
while (true) {
$status = $contactApi->contactsExportByIdStatusGet($exportId);
if ($status->getStatus() === 'completed') break;
sleep(5);
}
Never hardcode. Rotate out of band or via SecurityApi; the key used to create keys is not itself idempotent, so guard against double-creation in your own code:
$securityApi = new ElasticEmail\Api\SecurityApi($client, $config);
$newKey = $securityApi->securityApikeysPost(new \ElasticEmail\Model\AddApiKeyRequest([
'name' => 'Laravel App Key',
'accessLevel' => 'full_access',
]));
$file = new \GuzzleHttp\Psr7\Stream(fopen($path, 'r'));
$api->contactsImportPost(['file' => $file]);
Validate CSV structure before upload. If you retry a merge-file send, re-open the stream from the same local file (or cached bytes) so the multipart body stays identical.
Guzzle debug output is the fastest way to confirm the key is actually being sent:
$client = new GuzzleHttp\Client(['debug' => true]);
// Log the key alongside the message ID for support tickets
Log::info('elasticemail.send', ['key' => $key, 'message_id' => $result->getMessageID()]);
Check response codes: 400 invalid key, 409 in flight, 422 body mismatch, 503 unconfirmed key.
Bind the client for DI:
$this->app->bind(ElasticEmail\Api\EmailsApi::class, function ($app) {
return new ElasticEmail\Api\EmailsApi(
new GuzzleHttp\Client(),
ElasticEmail\Configuration::getDefaultConfiguration()
->setApiKey('X-ElasticEmail-ApiKey', config('services.elasticemail.api_key'))
);
});
Retry middleware works well with idempotency because it replays the same request object:
use GuzzleHttp\Middleware;
$stack = \GuzzleHttp\HandlerStack::create();
$stack->push(Middleware::retry(
fn ($retries, $request, $response = null, $e = null) =>
$retries < 3 && ($e !== null || in_array($response?->getStatusCode(), [409, 503], true)),
fn ($retries) => 1000 * 2 ** $retries
));
Testing:
$mock = Mockery::mock(ElasticEmail\Api\EmailsApi::class);
$mock->shouldReceive('emailsTransactionalPost')
->once()
->with(Mockery::type(\ElasticEmail\Model\SendTransactionalEmailRequest::class), Mockery::type('string'))
->andReturn(new \ElasticEmail\Model\SendTransactionalEmailResponse());
$this->app->instance(ElasticEmail\Api\EmailsApi::class, $mock);
Add an assertion that your job passes the same key on a forced retry — that's the regression that silently costs money.
$contentType — see #1; the highest-impact issue in this release.X-ElasticEmail-Signature header. Webhooks can be delivered more than once; dedupe on MessageID/event ID inside your handler.Custom models with extra validation:
class ExtendedContactRequest extends \ElasticEmail\Model\AddContactRequest
{
public function setCustomFields(array $fields)
{
$this->customFields = array_filter($fields, fn ($v) => strlen($v) <= 100);
return $this;
}
}
A tiny key factory keeps generation consistent and testable:
class IdempotencyKey
{
public static function for(string $scope, string $uuid): string
{
return substr("{$scope}:{$uuid}", 0, 255); // printable ASCII, ≤255
}
}
How can I help you explore Laravel packages today?