Search by

zapsmtp / zapsmtp-laravel

ZapSMTP

The official Laravel integration for the ZapSMTP email API: mail driver, webhooks, suppression sync, artisan commands, test fake

Package info

github.com/ZapSMTP/zapsmtp-laravel

Homepage

pkg:composer/zapsmtp/zapsmtp-laravel

Statistics

Installs: 17

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.2 2026-10-31 18:25 UTC

This package is auto-updated.

Last update: 2026-10-31 18:28:22 UTC


README

The official Laravel integration for ZapSMTP: a mail driver for Mailables, notifications and queued mail, webhooks as Laravel events, suppression sync, artisan commands, a test fake, and the full API client behind a facade.

Laravel 12.60+ and 13 (the releases with security support); PHP 8.2+ (8.3+ for Laravel 13); Octane-safe.

Install

composer require zapsmtp/zapsmtp-laravel
php artisan zapsmtp:install

zapsmtp:install publishes config/zapsmtp.php and adds ZAPSMTP_API_KEY=, MAIL_MAILER=zapsmtp and ZAPSMTP_WEBHOOK_SECRET= to .env and .env.example where they are missing (a value you have is never changed). Then put your key in .env:

MAIL_MAILER=zapsmtp
ZAPSMTP_API_KEY=zap_...

and check it:

php artisan zapsmtp:status                 # the key, your sending domains and their verification
php artisan zapsmtp:test you@example.com   # sends a test email, prints its id

The zapsmtp mailer is registered for you. To give it options, or a second mailer its own key, add it to config/mail.php:

'mailers' => [
    'zapsmtp' => ['transport' => 'zapsmtp'],
    'zapsmtp_marketing' => ['transport' => 'zapsmtp', 'key' => env('ZAPSMTP_MARKETING_KEY')],
],

Sending mail

Mailables, Mail::raw(), notifications and queued mail go through the ZapSMTP API unchanged: From, To, Cc, Bcc, Reply-To, subject, HTML and text, custom headers, attachments and inline images ($message->embed()). Laravel's tag() and metadata() become ZapSMTP tags and metadata (tag('category:receipt') is the tag category = receipt; a tag without a value is name = true). When a Symfony Envelope names other recipients than the message's headers (a staging sink, a redirect), the email goes to exactly the envelope's recipients, as To, and never to the header recipients.

ZapSMTP options on a Mailable

use ZapSMTP\Laravel\Mail\InteractsWithZapSMTP;

class OrderShipped extends Mailable implements ShouldQueue
{
    use InteractsWithZapSMTP, Queueable, SerializesModels;

    public function __construct(public Order $order)
    {
        $this->zapsmtpTags(['category' => 'shipping'])
            ->zapsmtpMetadata(['order_id' => $order->id])
            ->zapsmtpStream('transactional')            // or broadcast
            ->zapsmtpTracking(opens: true, clicks: true)
            ->zapsmtpScheduleAt(now()->addHour())        // or 'in 2 hours'
            ->zapsmtpIdempotencyKey("shipped-{$order->id}");
    }
}

Send a template stored in ZapSMTP instead of a view:

class Receipt extends Mailable
{
    use InteractsWithZapSMTP;

    public function __construct(Order $order)
    {
        $this->zapsmtpTemplate('receipt', ['name' => $order->customer_name, 'total' => $order->total]);
    }
}

The template's subject and body are used (pass a third argument to set the subject); From, the recipients, attachments and the other options still come from the Mailable. The options survive queueing.

Queued mail is sent once

Mail sent from a queued job (queued Mailables, queued notifications, or Mail::send() inside your own job) gets an Idempotency-Key derived from the job's uuid, the send's place in the job, its recipients and its subject. The key stays the same when the job is retried, so a retry after a timeout or a failure never sends the email twice; an email an earlier attempt already sent is skipped. zapsmtpIdempotencyKey() replaces the derived key; an empty one is refused.

Notifications

use ZapSMTP\Laravel\Notifications\ZapSMTPMailMessage;

public function toMail(object $notifiable): ZapSMTPMailMessage
{
    return (new ZapSMTPMailMessage())
        ->subject('Invoice paid')
        ->line('Thanks for your payment.')
        ->zapsmtpTags(['category' => 'invoice'])
        ->zapsmtpMetadata(['invoice_id' => $this->invoice->id]);
}

The email id

After sending, the ZapSMTP email id is the sent message's id:

$sent = Mail::to($user)->send(new OrderShipped($order));
$id = $sent->getMessageId();  // also ZapSMTP\Laravel\Mail\ZapSMTPTransport::emailId($sent)

Event::listen(function (\Illuminate\Mail\Events\MessageSent $e) {
    $id = $e->sent->getMessageId();
});

A failed send throws Symfony's TransportException wrapping the SDK's exception ($e->getPrevious()), so Laravel's failover mailer works.

Webhooks

Add the route (routes/web.php or routes/api.php; CSRF protection is turned off for it, the signature check replaces it):

Route::zapsmtpWebhooks();                    // POST /zapsmtp/webhooks
Route::zapsmtpWebhooks('/hooks/zapsmtp');    // or another path

then create the webhook in ZapSMTP and store its secret in .env:

php artisan zapsmtp:webhook                              # for APP_URL + the route
php artisan zapsmtp:webhook --event=email.bounced --event=email.complained
php artisan zapsmtp:webhook --rotate                     # a new secret for the existing webhook

Each request's Zap-Signature is checked with ZAPSMTP_WEBHOOK_SECRET (a bad one is answered 400), then every event (batched deliveries included) is dispatched as WebhookReceived and as its own class:

Event Extra accessors
EmailSent, EmailDelivered, EmailDeliveryDelayed, EmailFailed, EmailComplained, EmailSuppressed
EmailBounced bounceType(), bounceMessage(), isSuppressed()
EmailOpened isMachineOpen()
EmailClicked link(), action() (the click action), isUnique()
EmailReplied reply(), replyType()
EmailReceived, ThreadCreated, ThreadReplied (inbound mail; read it with ZapSMTP::emails()->receiving->get($e->emailId()))

All of them (namespace ZapSMTP\Laravel\Events) have type(), data(), emailId(), recipient(), tags(), metadata(), the event's id and the raw event.

use ZapSMTP\Laravel\Events\EmailClicked;

Event::listen(function (EmailClicked $e) {
    if ($e->action() === 'confirm_order' && $e->isUnique()) {
        Order::where('number', $e->metadata()['order_id'] ?? null)->update(['confirmed' => true]);
    }
});

ZapSMTP retries a delivery until it gets a 2xx, so an event can arrive twice: processed event ids are kept in the cache (3 days) and repeats are skipped. If a listener throws, the id is forgotten and ZapSMTP's retry delivers the event again. To answer at once and dispatch from your queue, set ZAPSMTP_WEBHOOK_QUEUE=true (and optionally ZAPSMTP_WEBHOOK_QUEUE_CONNECTION / ZAPSMTP_WEBHOOK_QUEUE_NAME); listeners can also implement ShouldQueue as usual.

Suppression sync

Stop emailing addresses that bounce or complain: publish the migration (it adds a nullable users.email_suppressed_at), migrate, and turn the sync on.

php artisan vendor:publish --tag=zapsmtp-migrations
php artisan migrate
ZAPSMTP_SUPPRESSION_SYNC=true

On email.bounced, email.complained and email.suppressed webhooks, the user whose email matches the recipient (case-insensitive) gets email_suppressed_at = now(). The model, the columns and the event types are set in config/zapsmtp.php (suppression). It needs the webhook route.

The API client

The ZapSMTP facade (or ZapSMTP\Client from the container) is the full PHP SDK client, configured from config/zapsmtp.php:

use ZapSMTP\Laravel\Facades\ZapSMTP;

ZapSMTP::emails()->send([...]);
foreach (ZapSMTP::emails()->list(['status' => 'bounced']) as $email) { ... }
ZapSMTP::domains()->verify($id);
ZapSMTP::suppressions()->create(['email' => 'old@example.org']);

public function __construct(private \ZapSMTP\Client $zap) {}

Testing your app

ZapSMTP::fake() stops real sends (by the mailer and by the client) and records them:

use ZapSMTP\Laravel\Facades\ZapSMTP;

ZapSMTP::fake();

$this->post('/checkout', [...]);

ZapSMTP::assertSentTo('jane@example.org');
ZapSMTP::assertSent(fn (array $email) => $email['subject'] === 'Your receipt');
ZapSMTP::assertSentTemplate('receipt', fn (array $variables) => $variables['total'] === 42);
ZapSMTP::assertSentCount(1);
ZapSMTP::assertNothingSent();

Every zapsmtp mailer uses the fake, mailers with their own key included. An email is the emails->send parameters the API would get (to is a list when the mailer sent it). Other API calls answer with empty results. Laravel's own Mail::fake() still works for asserting Mailables.

Configuration

config/zapsmtp.php (publish it with php artisan zapsmtp:install):

Key Environment Default
api_key ZAPSMTP_API_KEY, else ZAP_API_KEY required
base_url ZAPSMTP_BASE_URL, else ZAP_BASE_URL https://api.zapsmtp.com/v1
timeout ZAPSMTP_TIMEOUT 60 s per attempt
max_retries ZAPSMTP_MAX_RETRIES 2
webhook.secret ZAPSMTP_WEBHOOK_SECRET
webhook.tolerance ZAPSMTP_WEBHOOK_TOLERANCE 300 s
webhook.dedupe ZAPSMTP_WEBHOOK_CACHE_STORE on, the default cache store, 3 days
webhook.queue ZAPSMTP_WEBHOOK_QUEUE, ..._CONNECTION, ..._NAME off
suppression ZAPSMTP_SUPPRESSION_SYNC off; App\Models\User, email, email_suppressed_at

A wrong value fails with ZapSMTP\Laravel\Exceptions\InvalidConfigurationException naming the key (for example config zapsmtp.timeout must be a number above 0, string given).

Development

composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse

The package takes zapsmtp/zapsmtp-php from ../php (a path repository) while both live in this repository.