graystackit / laravel-ahasend-api
Laravel package for the Ahasend email API, powered by Saloon v4
Package info
github.com/GraystackIT/laravel-ahasend-api
pkg:composer/graystackit/laravel-ahasend-api
Requires
- php: ^8.2|^8.3|^8.4
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/mail: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
- saloonphp/saloon: ^4.0
- symfony/mailer: ^6.4|^7.0|^8.0
- symfony/mime: ^6.4|^7.0|^8.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
A production-ready Laravel package for the Ahasend transactional email API, powered by Saloon v4.
Requirements
- PHP 8.3+
- Laravel 11, 12, or 13
- Saloon 4.x
- Symfony Mailer 6.4 / 7.x (bundled with Laravel)
Installation
composer require graystackit/laravel-ahasend-api
The service provider is auto-discovered via Laravel's package discovery.
Publish config
php artisan vendor:publish --tag=ahasend-config
Publish & run migrations (optional — only needed for database storage driver)
php artisan vendor:publish --tag=ahasend-migrations php artisan migrate
Configuration
Set the following variables in your .env file:
AHASEND_API_KEY=your-api-key AHASEND_ACCOUNT_ID=your-account-id AHASEND_FROM_ADDRESS=hello@yourdomain.com AHASEND_FROM_NAME="Your App" # Optional AHASEND_BASE_URL=https://api.ahasend.com/v2 AHASEND_WEBHOOK_SECRET=your-webhook-secret AHASEND_STORE_LOGS=true AHASEND_STORAGE_DRIVER=database # "log" or "database" AHASEND_RETRY_TIMES=3 AHASEND_RETRY_DELAY_MS=500
Note:
AHASEND_ACCOUNT_IDis required. You can find your account ID in the Ahasend dashboard.
Usage
Dependency injection
use GraystackIT\Ahasend\AhasendService; class OrderController { public function __construct(private readonly AhasendService $mailer) {} public function confirm(): void { $this->mailer->sendHtml( to: [['email' => 'customer@example.com', 'name' => 'Jane']], subject: 'Order confirmed', htmlContent: '<p>Your order is confirmed!</p>', textContent: 'Your order is confirmed!', ); } }
Plain-text email
$mailer->sendText( to: [['email' => 'user@example.com']], subject: 'Hello', textContent: 'Hello from Ahasend!', );
HTML email
$mailer->sendHtml( to: [['email' => 'user@example.com']], subject: 'Hello', htmlContent: '<h1>Hello!</h1>', textContent: 'Hello!', // optional plain-text fallback );
Email with attachments
$mailer->sendWithAttachments( to: [['email' => 'user@example.com']], subject: 'Your invoice', attachments: [ ['path' => storage_path('invoices/inv-001.pdf')], // file path ['name' => 'data.csv', 'content' => $csvBase64, 'mime_type' => 'text/csv'], // raw ], htmlContent: '<p>Please find your invoice attached.</p>', );
CC / BCC
Pass cc and bcc arrays to any convenience method. When CC or BCC recipients are present the package automatically routes the request to the Ahasend conversational endpoint (POST /messages/conversation), which is the only endpoint that supports those fields:
$mailer->sendHtml( to: [['email' => 'a@example.com']], subject: 'Test', htmlContent: '<p>Hi</p>', cc: [['email' => 'b@example.com']], bcc: [['email' => 'c@example.com']], );
Low-level EmailMessage
use GraystackIT\Ahasend\Data\EmailMessage; $message = new EmailMessage( fromEmail: 'from@example.com', fromName: 'Sender', to: [['email' => 'to@example.com']], subject: 'Custom', htmlContent: '<p>Hello</p>', ); $ahasendMessageId = $mailer->send($message);
EmailMessage also accepts these optional fields, all passed straight through to the Ahasend API: tags (string[]), tracking (['open' => bool, 'click' => bool]), schedule (['first_attempt' => ..., 'expires' => ...], RFC3339), retention (['metadata' => int, 'data' => int], days), substitutions (template variables — not supported on the conversational/CC-BCC endpoint), sandboxResult (deliver/bounce/defer/fail/suppress), sandbox (bool — route through AhaSend's sandbox without sending real mail), replyTo (['email' => ..., 'name' => ...]), headers (custom header map), and ampContent (AMP4EMAIL body).
Webhook handling
Register your endpoint URL in the Ahasend dashboard:
https://yourdomain.com/ahasend/webhook
The path is configurable via AHASEND_WEBHOOK_PATH. Incoming events fire Laravel events you can listen to:
| Ahasend event | Laravel event |
|---|---|
message.delivered |
MailDelivered |
message.opened |
MailOpened |
message.failed |
MailFailed |
message.bounced |
MailBounced |
Listening to events
// In EventServiceProvider or a listener class: Event::listen(MailDelivered::class, function (MailDelivered $event): void { // $event->messageId, $event->recipient, $event->payload });
Laravel Mail driver
The package registers a native Laravel mail transport driver so you can send any standard Laravel Mailable through AhaSend without touching your existing Mailable code.
1. Configure the mailer
Add an ahasend entry to the mailers array in config/mail.php:
// config/mail.php 'mailers' => [ // ... other mailers ... 'ahasend' => [ 'transport' => 'ahasend', ], ],
The transport reads API credentials and sender defaults from the ahasend config (i.e. the same AHASEND_* variables you already set).
To make AhaSend the default mailer, update your .env:
MAIL_MAILER=ahasend
2. Send a Mailable
use App\Mail\OrderShipped; use Illuminate\Support\Facades\Mail; // Uses the default mailer if MAIL_MAILER=ahasend Mail::to('customer@example.com')->send(new OrderShipped($order)); // Or target the driver explicitly Mail::mailer('ahasend') ->to('customer@example.com') ->cc('manager@example.com') ->send(new OrderShipped($order));
3. Example Mailable
<?php namespace App\Mail; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; class OrderShipped extends Mailable { public function __construct(public readonly Order $order) {} public function envelope(): Envelope { return new Envelope(subject: 'Your order has shipped'); } public function content(): Content { return new Content( html: 'emails.order-shipped', // resources/views/emails/order-shipped.blade.php text: 'emails.order-shipped-text', ); } public function attachments(): array { return [ Attachment::fromPath(storage_path("invoices/{$this->order->id}.pdf")) ->as('invoice.pdf') ->withMime('application/pdf'), ]; } }
4. Required .env variables
AHASEND_API_KEY=your-api-key AHASEND_ACCOUNT_ID=your-account-id AHASEND_FROM_ADDRESS=hello@yourdomain.com AHASEND_FROM_NAME="Your App" # Make AhaSend the default mailer MAIL_MAILER=ahasend
Supported features
| Feature | Supported |
|---|---|
| HTML body | Yes |
| Plain-text body | Yes |
Multiple To recipients |
Yes |
| CC | Yes |
| BCC | Yes |
| File attachments | Yes (auto base64 encoded) |
| From address / name | Yes (from Mailable or config fallback) |
Transport internals
The driver is implemented as GraystackIT\Ahasend\Mail\AhaSendTransport, which extends Symfony's AbstractTransport. It converts the Symfony Email object into the EmailMessage DTO used by AhasendService::send(), preserving all recipients, headers, and attachments. Errors thrown by AhasendService are re-wrapped as Symfony TransportException so Laravel's mail system handles them consistently.
Mailable tracking
Use the TracksAhasendMail trait in any Mailable to attach a UUID X-Ahasend-Message-Id header and (optionally) store the outgoing record in the database:
use GraystackIT\Ahasend\Traits\TracksAhasendMail; use Illuminate\Mail\Mailable; class OrderShipped extends Mailable { use TracksAhasendMail; public function build(): self { $this->initAhasendTracking(recipient: $this->order->email); return $this->subject('Your order has shipped') ->view('emails.order-shipped'); } }
Messages
Retrieve and manage sent or scheduled messages via MessageService.
use GraystackIT\Ahasend\Services\MessageService; use GraystackIT\Ahasend\Enums\MessageStatus; class MyController { public function __construct(private readonly MessageService $messages) {} }
Get a single message
$message = $messages->get('msg-abc123'); echo $message->id; // 'msg-abc123' echo $message->subject; // 'Hello World' echo $message->sender; // 'sender@example.com' echo $message->recipient; // 'recipient@example.com' echo $message->status->value; // 'delivered' echo $message->status->isTerminal(); // true
List messages
Uses cursor-based pagination, plus optional filters:
$result = $messages->list( limit: 25, // optional — max results to return after: 'cursor-xyz', // optional — cursor for the next page before: 'cursor-abc', // optional — cursor for the previous page status: 'delivered', // optional sender: 'sender@example.com', // optional recipient: 'recipient@example.com', // optional tags: 'welcome', // optional — comma-separated fromTime: '2026-01-01T00:00:00Z', // optional — RFC3339 toTime: '2026-01-31T23:59:59Z', // optional — RFC3339 ); foreach ($result['data'] as $message) { echo $message->id . ': ' . $message->subject; } // $result['pagination'] contains has_more / next_cursor / previous_cursor
Cancel a scheduled message
$cancelled = $messages->cancel('msg-scheduled-001'); // true on success
SMTP Credentials
Manage programmatic SMTP credentials via SmtpCredentialService.
use GraystackIT\Ahasend\Services\SmtpCredentialService; class MyController { public function __construct(private readonly SmtpCredentialService $smtp) {} }
Create an SMTP credential
// Global credential (can send from any domain) $credential = $smtp->create('My Application'); // Scoped credential (restricted to specific domains) $credential = $smtp->create( name: 'My Application', scope: 'scoped', domains: ['yourdomain.com', 'anotherdomain.com'], ); // Sandbox credential (no real emails sent) $credential = $smtp->create('Test App', sandbox: true); // Save the password — the API will not return it again. echo $credential->id; // 'cred-xyz' echo $credential->username; // 'smtp_my_application' echo $credential->password; // 'generated-secret' (only available on create) echo $credential->host; // 'send.ahasend.com' (EU) or 'send-us.ahasend.com' (US) echo $credential->port; // 587 — also available: 25, 2525 (STARTTLS required; port 465 is not supported)
List all SMTP credentials
Uses cursor-based pagination:
$credentials = $smtp->list( limit: 10, // optional after: 'cursor-xyz', // optional before: 'cursor-abc', // optional ); foreach ($credentials as $cred) { echo $cred->id . ': ' . $cred->name; }
Get a single SMTP credential
$credential = $smtp->get('cred-xyz');
Delete an SMTP credential
$smtp->delete('cred-xyz'); // true on success
Suppressions
Manage the suppression list via SuppressionService.
use GraystackIT\Ahasend\Services\SuppressionService; class MyController { public function __construct(private readonly SuppressionService $suppressions) {} }
Add a suppression
$suppression = $suppressions->create( email: 'user@example.com', expiresAt: '2026-12-31T00:00:00Z', // RFC3339 datetime — required reason: 'User unknown', // optional domain: 'example.com', // optional — restrict to a sending domain ); echo $suppression->id; // 'sup-xyz' echo $suppression->email; // 'user@example.com'
List suppressions
Uses cursor-based pagination, plus optional filters:
$result = $suppressions->list( limit: 50, // optional after: 'cursor-xyz', // optional before: 'cursor-abc', // optional domain: 'example.com', // optional — filter by sending domain email: 'user@example.com', // optional — filter by recipient email fromTime: '2026-01-01T00:00:00Z', // optional — RFC3339, created after toTime: '2026-01-31T23:59:59Z', // optional — RFC3339, created before ); foreach ($result['data'] as $suppression) { echo $suppression->email; } // $result['meta'] contains cursor pagination info
Delete a specific suppression
Deletes by email (optionally scoped to a sending domain):
$suppressions->delete('user@example.com'); // true on success $suppressions->delete('user@example.com', domain: 'example.com'); // scoped to one domain
Delete all suppressions
Optionally scoped to a single sending domain:
$suppressions->deleteAll(); // true on success — wipes the entire account list $suppressions->deleteAll(domain: 'example.com'); // wipes only suppressions for this domain
Reports
Retrieve analytics data via ReportService.
use GraystackIT\Ahasend\Services\ReportService; class MyController { public function __construct(private readonly ReportService $reports) {} }
All date/time parameters use RFC3339 format (e.g. 2024-01-01T00:00:00Z). Every report method returns a list of time-bucketed entries (one per group_by interval — hour, day, week, or month; defaults to day), matching AhaSend's statistics response shape. Note: AhaSend rate-limits these three statistics endpoints to 1 request/second with no burst allowance, versus 100 req/sec elsewhere — avoid tight polling loops.
Bounce statistics
$buckets = $reports->bounceStatistics( fromTime: '2024-01-01T00:00:00Z', // optional toTime: '2024-01-31T23:59:59Z', // optional senderDomain: 'gmail.com', // optional — filter by sending domain recipientDomains: 'gmail.com,outlook.com', // optional — comma-separated tags: 'transactional', // optional — comma-separated groupBy: 'day', // optional — hour, day, week, month ); foreach ($buckets as $bucket) { echo $bucket->fromTimestamp . ' – ' . $bucket->toTimestamp . "\n"; foreach ($bucket->bounces as $bounce) { echo " {$bounce['classification']}: {$bounce['count']}\n"; } }
Deliverability breakdown
$buckets = $reports->deliverabilityBreakdown( fromTime: '2024-01-01T00:00:00Z', // optional toTime: '2024-01-31T23:59:59Z', // optional senderDomain: 'yourdomain.com', // optional recipientDomains: 'gmail.com,outlook.com', // optional — comma-separated tags: 'transactional,welcome', // optional — comma-separated groupBy: 'day', // optional — hour, day, week, month ); foreach ($buckets as $bucket) { echo $bucket->fromTimestamp . ': ' . $bucket->deliveredCount . ' delivered, ' . $bucket->bouncedCount . ' bounced'; }
Delivery time analytics
$buckets = $reports->deliveryTimeAnalytics( fromTime: '2024-01-01T00:00:00Z', // optional toTime: '2024-01-31T23:59:59Z', // optional senderDomain: 'yahoo.com', // optional recipientDomains: 'yahoo.com', // optional — comma-separated tags: 'transactional', // optional — comma-separated groupBy: 'day', // optional — hour, day, week, month ); foreach ($buckets as $bucket) { echo $bucket->fromTimestamp . ': avg ' . $bucket->avgDeliveryTime . 's across ' . $bucket->deliveredCount . ' messages'; foreach ($bucket->deliveryTimes as $byDomain) { echo " {$byDomain['recipient_domain']}: {$byDomain['delivery_time']}s ({$byDomain['count']} messages)\n"; } }
Error handling
All service methods throw AhasendException on API errors. The exception wraps the Saloon RequestException and exposes the HTTP status code.
use GraystackIT\Ahasend\Exceptions\AhasendException; try { $messages->get('nonexistent-id'); } catch (AhasendException $e) { echo $e->getCode(); // 404 echo $e->getMessage(); // "Ahasend API error [404]: ..." }
Testing
composer test
License
MIT