Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Elasticemail Php Laravel Package

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+.

View on GitHub
Deep Wiki
Context7

Getting Started

Minimal Setup

  1. 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.

  2. 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'));
    
  3. 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.

Key Files to Explore

  • 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).

Implementation Patterns

1. Service Layer Abstraction

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.

2. Generate the Key Once, Persist It

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.

3. Queue Job With Safe Retries

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.

4. Bulk / Merge Sends

$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.

5. Detecting Replays

...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()]);
}

6. Event Handling

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".

7. Error Handling

// 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);
}

Gotchas and Tips

1. ⚠️ Breaking: New Argument Sits Before $contentType

The 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.

2. One Key Per Logical Send — Never Per Attempt

// 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); }
}

3. Body Must Be Byte-Identical

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.
  • Re-ordering associative keys / re-encoding JSON with different flags between attempts.
  • Generating message IDs client-side per attempt.
  • Retrying through a different endpoint (e.g. /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.

4. Status Code Playbook

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.

5. Scope and Lifetime

  • Keys are per account; subaccounts don't share them. If you send from multiple subaccounts, keys can legitimately repeat across them.
  • Keys live 24 hours from the first request. There's no client-side expiry — clean up your idempotency_key columns accordingly.
  • Coverage is limited to three endpoints: /emails/transactional, /emails, /emails/mergefile. Campaign sends, contact imports and exports are not idempotent — keep polling-based reconciliation for those.

6. Key Format

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.

7. Concurrency & Timeouts (unchanged)

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);
}

8. API Key Management

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',
]));

9. CSV / Attachment Handling

$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.

10. Debugging

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.

11. Laravel-Specific Tips

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.

12. Common Pitfalls

  • Positional $contentType — see #1; the highest-impact issue in this release.
  • Case Sensitivity: Campaign and list names are case-sensitive.
  • Async Operations: Exports and bulk sends are async; always check status.
  • Field Limits: Custom fields have a 100-character limit.
  • Webhook Signatures: Verify payloads using the X-ElasticEmail-Signature header. Webhooks can be delivered more than once; dedupe on MessageID/event ID inside your handler.

13. Extending the Package

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
    }
}
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel