zapsmtp / zapsmtp-laravel
The official Laravel integration for the ZapSMTP email API: mail driver, webhooks, suppression sync, artisan commands, test fake
Requires
- php: ^8.2
- guzzlehttp/psr7: ^2.4 || ^3.0
- illuminate/bus: ^12.60 || ^13.0
- illuminate/cache: ^12.60 || ^13.0
- illuminate/console: ^12.60 || ^13.0
- illuminate/contracts: ^12.60 || ^13.0
- illuminate/database: ^12.60 || ^13.0
- illuminate/http: ^12.60 || ^13.0
- illuminate/mail: ^12.60 || ^13.0
- illuminate/notifications: ^12.60 || ^13.0
- illuminate/queue: ^12.60 || ^13.0
- illuminate/routing: ^12.60 || ^13.0
- illuminate/support: ^12.60 || ^13.0
- psr/http-client: ^1.0
- symfony/mailer: ^7.2 || ^8.0
- symfony/mime: ^7.2 || ^8.0
- zapsmtp/zapsmtp-php: ^0.1
Requires (Dev)
- orchestra/testbench: ^10.11 || ^11.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^11.5.3 || ^12.0
Suggests
- phpunit/phpunit: For ZapSMTP::fake() assertions in your tests
Provides
None
Conflicts
None
Replaces
None
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.