Frameworks

Laravel

Use the official Lettermint Laravel package 3.0 with Mailables, notifications, queued mail, tags, metadata, and idempotency. It also registers one shared Lettermint client for direct API calls, and receives verified webhooks as Laravel events. The package uses the PHP SDK 3.0. See Integrations for other packages.

Upgrading from 2.x? Follow the Laravel package 3.0 upgrade guide on GitHub.

Requirements

Before you start you need:

  • PHP 8.2 or newer
  • Laravel 10, 11, 12, or 13
  • A Lettermint account with a verified sending domain
  • A Project API token from your project settings
  • Optional: a Team API token, to manage domains, projects, routes, and messages from your app

If your domain is not verified yet, follow the domain setup guide first so your mail reaches the inbox instead of the spam folder.

1. Installation

Install the package via Composer:

TerminalCode
composer require lettermint/lettermint-laravel:^3.0

Publish the configuration file:

TerminalCode
php artisan vendor:publish --tag="lettermint-config"

2. Configuration

Environment variables

Add your Lettermint credentials to .env:

.env
# Sends email: the mail transport and Lettermint::emails() LETTERMINT_PROJECT_TOKEN=your-lettermint-project-token # Optional: the Team API (domains, messages, projects, routes, webhooks, ...) LETTERMINT_TEAM_TOKEN=your-lettermint-team-token LETTERMINT_ROUTE_ID=your-route-id
VariableConfig keyUsed by
LETTERMINT_PROJECT_TOKENlettermint.tokenThe mail transport and Lettermint::emails()
LETTERMINT_TEAM_TOKENlettermint.api_tokenEvery other part of the client

Configure one token or both. Each part uses its own token and never falls back to the other one. With only a project token you can send email, and a Team API call throws a LettermintConfigException that names the missing token before any request is made.

The names used by earlier versions still work. LETTERMINT_TOKEN is read when LETTERMINT_PROJECT_TOKEN is not set, and LETTERMINT_API_TOKEN when LETTERMINT_TEAM_TOKEN is not set. Prefer the new names in new applications.

Requests time out after 15 seconds, covering the whole request including reading the response. Change it with LETTERMINT_TIMEOUT=30 (seconds).

Services configuration

Add to config/services.php:

PHPconfig/services.php
'lettermint' => [ 'token' => env('LETTERMINT_PROJECT_TOKEN'), 'api_token' => env('LETTERMINT_TEAM_TOKEN'), ],

api_token is only needed for the Team API. Use a Team API token for that value. Values in config/lettermint.php take precedence over config/services.php.

Mail configuration

Add the Lettermint mailer to config/mail.php:

PHPconfig/mail.php
'mailers' => [ // ... other mailers 'lettermint' => [ 'transport' => 'lettermint', 'route_id' => env('LETTERMINT_ROUTE_ID'), 'idempotency' => true, 'idempotency_window' => 86400, ], ],

Set Lettermint as your default mailer in .env:

.env
MAIL_MAILER=lettermint

3. Send your first email

Send emails using Laravel's standard Mail facade:

PHPCode
use App\Mail\WelcomeEmail; use Illuminate\Support\Facades\Mail; Mail::to('recipient@example.com')->send(new WelcomeEmail($user));

Or specify the mailer explicitly:

PHPCode
Mail::mailer('lettermint')->to('recipient@example.com')->send(new WelcomeEmail($user));

Queued and bulk sending

Because Lettermint is a standard Laravel mail driver, it works with Laravel's queue and notification systems out of the box. Queue a message so sending happens in the background:

PHPCode
Mail::to('recipient@example.com')->queue(new WelcomeEmail($user));

Or have a Mailable always queue by implementing ShouldQueue:

PHPCode
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Mail\Mailable; class WelcomeEmail extends Mailable implements ShouldQueue { // ... }

For higher volumes, dispatch onto a dedicated queue and process it with Horizon or queue:work, exactly as you would with any Laravel mailer.

Handle errors

When Lettermint rejects an email or cannot be reached, the transport throws Symfony's TransportException. Its code is the HTTP status (0 when no response was received), and getPrevious() returns the SDK exception with the details:

PHPCode
use Illuminate\Support\Facades\Mail; use Lettermint\Exceptions\RateLimitException; use Lettermint\Exceptions\ValidationException; use Symfony\Component\Mailer\Exception\TransportException; try { Mail::to('recipient@example.com')->send(new WelcomeEmail($user)); } catch (TransportException $e) { $error = $e->getPrevious(); if ($error instanceof ValidationException) { report($e); // $error->errors holds the field errors } elseif ($error instanceof RateLimitException) { // Retry after $error->retryAfter seconds, with the same idempotency key. } }

See the PHP SDK guide for every exception class.

4. Tags

Categorize emails for filtering and analytics in your Lettermint dashboard.

Using the tag() method

PHPCode
Mail::to('recipient@example.com') ->send((new WelcomeEmail($user))->tag('onboarding'));

In mailable envelope

PHPCode
use Illuminate\Mail\Mailables\Envelope; public function envelope(): Envelope { return new Envelope( subject: 'Welcome to Our Platform', tags: ['onboarding'], ); }

This SDK example uses the existing singular tag. It has no deprecation date. Use the raw HTTP API to send structured tags until this SDK has a multi-tag method. See Tags documentation.

5. Metadata

Attach custom data for tracking and webhook payloads.

Using the metadata() method

PHPCode
Mail::to('customer@example.com') ->send( (new OrderConfirmation($order)) ->metadata('order_id', $order->id) ->metadata('customer_id', $order->customer_id) );

In mailable envelope

PHPCode
use Illuminate\Mail\Mailables\Envelope; public function envelope(): Envelope { return new Envelope( subject: 'Order Confirmation', metadata: [ 'order_id' => $this->order->id, 'customer_id' => $this->order->customer_id, 'order_total' => $this->order->total, ], ); }

Metadata is included in webhook payloads but not sent to recipients.

6. Multiple routes

Configure separate mailers for different email types (transactional, marketing, etc.):

PHPconfig/mail.php
'mailers' => [ 'lettermint_transactional' => [ 'transport' => 'lettermint', 'route_id' => env('LETTERMINT_TRANSACTIONAL_ROUTE_ID'), ], 'lettermint_marketing' => [ 'transport' => 'lettermint', 'route_id' => env('LETTERMINT_MARKETING_ROUTE_ID'), ], ],
.env
LETTERMINT_TRANSACTIONAL_ROUTE_ID=your-transactional-route-id LETTERMINT_MARKETING_ROUTE_ID=your-marketing-route-id

Send to specific routes:

PHPCode
// Transactional emails (password resets, order confirmations) Mail::mailer('lettermint_transactional') ->to($user) ->send(new PasswordResetEmail()); // Marketing emails (newsletters, promotions) Mail::mailer('lettermint_marketing') ->to($user) ->send(new NewsletterEmail());

7. Idempotency

Prevent duplicate emails when retrying failed requests.

Configuration

PHPconfig/mail.php
'lettermint' => [ 'transport' => 'lettermint', 'idempotency' => true, // Enable automatic deduplication 'idempotency_window' => 86400, // Window in seconds (24 hours) ],

Custom idempotency key

Override the automatic key for specific emails. The Idempotency-Key header is sent as a request header, not as an email header, and always takes precedence over the automatic key:

PHPCode
use Illuminate\Mail\Mailables\Headers; public function headers(): Headers { return new Headers( text: [ 'Idempotency-Key' => "welcome-{$this->user->id}", ], ); }

The automatic key is a hash of the subject, the recipients, the body, and the sender. Keep idempotency_window at 86400 seconds to align with Lettermint's 24-hour idempotency key validity. Keys are scoped per project; retrying the same payload returns the cached 202 Accepted response, while reusing a key with a different payload returns 409 Conflict.

8. Use the Lettermint client

The package registers one Lettermint\Lettermint client in the container, built from your configuration. It holds no message state, so the same instance serves the mail transport, queue workers, and Octane. Use it through the Lettermint facade, dependency injection, or the container.

Send an email with the facade. The idempotency key is an argument of the call:

PHPCode
use Lettermint\Laravel\Facades\Lettermint; $result = Lettermint::emails()->send([ 'from' => 'Acme <hello@acme.com>', 'to' => ['jane@example.com'], 'subject' => 'Welcome to Acme', 'html' => '<p>Thanks for signing up.</p>', ], idempotencyKey: "welcome-{$user->id}"); $result->message_id;

Or inject the client. On an injected client, the parts are properties ($lettermint->emails, $lettermint->domains), and compose() returns an immutable builder:

PHPCode
use Lettermint\Lettermint; class InvoiceMailer { public function __construct(private Lettermint $lettermint) {} public function send(Invoice $invoice): void { $this->lettermint->emails->compose() ->from('billing@acme.com') ->to($invoice->customer->email) ->subject("Invoice {$invoice->number}") ->html(view('emails.invoice', ['invoice' => $invoice])->render()) ->send(idempotencyKey: "invoice-{$invoice->id}"); } }

The facade exposes each part of the client as a method (Lettermint::emails(), Lettermint::domains(), and so on) and forwards the client's methods, such as Lettermint::ping(). The PHP guide covers the email builder, batch sending, scheduling, pagination, and response types.

Team API

With LETTERMINT_TEAM_TOKEN configured, the same facade reaches the Team API:

PHPCode
use Lettermint\Laravel\Facades\Lettermint; $domains = Lettermint::domains()->list(['filter' => ['status' => 'verified']]); foreach (Lettermint::messages()->iterate(['page' => ['size' => 100]]) as $message) { // Follows the cursor through every page. } $team = Lettermint::team()->retrieve();

Queued jobs

The client refuses to be serialized because it holds your tokens. Do not store it in a property of a queued job, event, or listener. Resolve it where you use it, for example as a handle() argument:

PHPCode
public function handle(Lettermint $lettermint): void { $lettermint->emails->send($this->message); }

Custom HTTP client

To send the client's requests through your own Guzzle client, for example with a proxy or a test handler, bind it before the client is first resolved:

PHPCode
use GuzzleHttp\Client; use Lettermint\Laravel\LettermintServiceProvider; $this->app->bind(LettermintServiceProvider::HTTP_CLIENT, fn () => new Client(['proxy' => 'http://proxy.internal:3128']));

9. Webhooks

The package receives Lettermint webhooks, verifies their signature with the PHP SDK, and dispatches a Laravel event for each delivery.

Configuration

Add the webhook's signing secret, including its whsec_ prefix, to .env:

.env
LETTERMINT_WEBHOOK_SECRET=whsec_... LETTERMINT_WEBHOOK_PREFIX=lettermint LETTERMINT_WEBHOOK_TOLERANCE=300

The prefix and the timestamp tolerance (in seconds) are optional. The package registers the endpoint POST /{prefix}/webhook, named lettermint.webhook. By default that is POST /lettermint/webhook. Add its URL to the webhook in your Lettermint dashboard.

Verification

Every request must carry the X-Lettermint-Signature and X-Lettermint-Delivery headers that Lettermint sends, and the raw body exactly as it was sent. If a proxy or load balancer sits in front of your application, make sure it forwards both headers and does not change the body.

A request that fails verification gets a 401 response whose reason says why, for example signature_mismatch or timestamp_out_of_tolerance, and no event is dispatched. A verified delivery is acknowledged with 200.

Custom webhook routes

To register the endpoint yourself, for example on a specific domain, disable the automatic route:

.env
LETTERMINT_WEBHOOK_ENABLED=false

Then register the controller and the signature middleware in your routes file:

PHPCode
use Illuminate\Support\Facades\Route; use Lettermint\Laravel\Webhooks\VerifyWebhookSignature; use Lettermint\Laravel\Webhooks\WebhookController; Route::post( config('lettermint.webhooks.prefix', 'lettermint').'/webhook', WebhookController::class )->name('lettermint.webhook') ->middleware(VerifyWebhookSignature::class);

You can wrap the registration in Route::domain(...) or another route group. If you handle the request in your own controller, VerifyWebhookSignature::payload($request) returns the verified Lettermint\WebhookPayload. The package's own route runs without the web middleware group. If you register the route in routes/web.php, exclude it from CSRF validation so Laravel does not reject Lettermint's request.

Available events

Event classWebhook typeDescription
MessageCreatedmessage.createdMessage accepted for processing
MessageSentmessage.sentMessage sent to the recipient's server
MessageDeliveredmessage.deliveredMessage delivered
MessageHardBouncedmessage.hard_bouncedPermanent delivery failure
MessageSoftBouncedmessage.soft_bouncedTemporary delivery failure
MessageSpamComplaintmessage.spam_complaintRecipient reported spam
MessageFailedmessage.failedProcessing failure
MessageSuppressedmessage.suppressedMessage suppressed
MessagePolicyRejectedmessage.policy_rejectedRejected by the sending policy
MessageUnsubscribedmessage.unsubscribedRecipient unsubscribed
MessageOpenedmessage.openedRecipient opened the email
MessageClickedmessage.clickedRecipient clicked a link
MessageInboundmessage.inboundInbound email received
MessageAutoRepliedmessage.auto_repliedAn automatic reply was received
MessageScheduledmessage.scheduledA message was scheduled
MessageRescheduledmessage.rescheduledA message's schedule changed
MessageCanceledmessage.canceledA scheduled message was canceled
MessageReleasedmessage.releasedA scheduled message was released
SuppressionAddedsuppression.addedSuppression entry added
SuppressionRemovedsuppression.removedSuppression entry removed
WebhookTestwebhook.testTest event from the dashboard

All event classes are in the Lettermint\Laravel\Events namespace.

Listening to events

Listen to the events you need:

PHPapp/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Lettermint\Laravel\Events\MessageDelivered; use Lettermint\Laravel\Events\MessageHardBounced; public function boot(): void { Event::listen(MessageDelivered::class, function (MessageDelivered $event) { Log::info('Email delivered', [ 'message_id' => $event->data->messageId, 'recipient' => $event->data->recipient, 'status_code' => $event->data->response->statusCode, ]); }); Event::listen(MessageHardBounced::class, function (MessageHardBounced $event) { // A permanent bounce: consider disabling the recipient. $recipient = $event->data->recipient; $reason = $event->data->response->content; }); }

Or use listener classes:

PHPapp/Listeners/HandleEmailDelivered.php
namespace App\Listeners; use Lettermint\Laravel\Events\MessageDelivered; class HandleEmailDelivered { public function handle(MessageDelivered $event): void { logger('Email delivered', [ 'message_id' => $event->data->messageId, 'recipient' => $event->data->recipient, ]); } }

Laravel 11 and newer discover a listener in app/Listeners from the type hint of its handle() method, so you register nothing. On Laravel 10, register it in EventServiceProvider:

PHPapp/Providers/EventServiceProvider.php
protected $listen = [ \Lettermint\Laravel\Events\MessageDelivered::class => [ \App\Listeners\HandleEmailDelivered::class, ], ];

Do not register a discovered listener again by hand, or it runs twice.

Event structure

Each typed event has three properties:

PHPCode
// The envelope, common to all events $event->envelope->id; // Delivery ID (string) $event->envelope->event; // WebhookEventType enum $event->envelope->timestamp; // ?DateTimeImmutable // The typed data, per event (here MessageDelivered) $event->data->messageId; // string $event->data->recipient; // string $event->data->response->statusCode; // ?int $event->data->response->content; // ?string $event->data->metadata; // array $event->data->tag; // ?string // The complete verified payload, as decoded from the request $event->payload['context']; // e.g. ['scope' => 'project', 'project_id' => '...', ...] $event->payload['data']['tags']; // fields the typed data does not map

The typed data tolerates changes to the payload. A field Lettermint omits, or sends with an unexpected type, reads as null (or an empty list) instead of failing the delivery, and fields the package does not know stay available in $event->payload. Fields that Lettermint may omit or send as null are nullable, so check them before you use them. Identifiers that every delivery carries, such as messageId, are strings.

Inbound attachments are delivered as base64 content, or as a signed url (with expiresAt) when the route delivers attachment URLs. $attachment->getDecodedContent() returns the raw bytes, or null for a URL attachment.

The WebhookEventType enum groups event types:

PHPCode
$event->envelope->event->isBounce(); // true for hard and soft bounces $event->envelope->event->isDeliveryIssue(); // true for bounces, failed, suppressed, and policy-rejected messages

Unknown event types

When Lettermint sends an event type that your version of the package does not know yet, the delivery is still acknowledged with a 200 response, and an UnknownWebhookEventReceived event is dispatched with the event name and the complete verified payload. Update the package to receive a typed event instead.

PHPCode
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Lettermint\Laravel\Events\UnknownWebhookEventReceived; Event::listen(UnknownWebhookEventReceived::class, function (UnknownWebhookEventReceived $event) { Log::info('Unhandled Lettermint webhook', [ 'type' => $event->event, // e.g. "message.some_new_event" 'id' => $event->payload['id'] ?? null, ]); });

Listening to all events

Every webhook event implements the Lettermint\Laravel\Contracts\WebhookEvent interface, so one listener on it receives all of them: every typed event, and UnknownWebhookEventReceived.

PHPCode
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Log; use Lettermint\Laravel\Contracts\WebhookEvent; use Lettermint\Laravel\Events\LettermintWebhookEvent; use Lettermint\Laravel\Events\UnknownWebhookEventReceived; Event::listen(WebhookEvent::class, function (WebhookEvent $event) { if ($event instanceof UnknownWebhookEventReceived) { Log::info('Unknown webhook received', ['type' => $event->event]); return; } /** @var LettermintWebhookEvent $event */ Log::info('Webhook received', [ 'type' => $event->getEnvelope()->event->value, 'id' => $event->getEnvelope()->id, ]); });

A listener on the abstract LettermintWebhookEvent class receives nothing. Laravel matches listeners on an event's own class and its interfaces, not its parent classes. Use the WebhookEvent interface.

FAQ

Which Laravel and PHP versions are supported?

PHP 8.2 or newer and Laravel 10, 11, 12, or 13. The package is tested against every supported Laravel version on PHP 8.2 to 8.5.

Do Mailables, notifications, and queued mail work as usual?

Yes. Lettermint registers as a standard mail transport, so everything you already build with the Mail facade, Mailables, notifications, and queues keeps working. Only the delivery happens through Lettermint.

How do I prevent duplicate emails?

Set 'idempotency' => true on the mailer, with a 24-hour window (idempotency_window). Retrying the same payload then returns the cached result instead of sending again. Override the key per message with an Idempotency-Key header, as shown in the idempotency section above.

How do I track deliveries and bounces?

The package verifies Lettermint's webhooks and dispatches Laravel events such as MessageDelivered and MessageHardBounced, so you can react in your app. For opens and clicks, enable tracking on the route your mailer uses.

Is Lettermint email GDPR compliant and EU-hosted?

Yes. Lettermint runs exclusively on European infrastructure and processes email in line with GDPR, so transactional mail from your Laravel app is handled inside the EU.

Next steps

Tags

Organize and filter emails with tags.

Tracking

Track opens, clicks, and deliverability.

Webhooks

Receive delivery events through webhooks.

SMTP Alternative

Send via SMTP instead of the API.

GitHub Repository

Find the complete source code, report issues, or contribute on GitHub.