dyaskur/sendfox-php
A simple PHP client for the SendFox API, giving Laravel and plain PHP apps an easy way to manage contacts, lists, campaigns, and automations with a clean, framework-friendly wrapper.
This package (dyaskur/sendfox-php) adds a Symfony Mailer transport for SendFox’s transactional API, auto-registers a sendfox mailer, and as of v0.3.0 also ships a signature-verified webhook endpoint, built-in API retries, idempotent sends, and lazy pagination for the API client. Treat it as a dedicated transactional mailer plus a small SendFox SDK — not a drop-in SMTP replacement.
Install and let Laravel discover the service provider (confirm the Composer name against Packagist, since the changelog links the GitHub repo rather than a composer require line):
composer require dyaskur/sendfox-php:0.3.*
php artisan package:discover
Confirm you are on Laravel 9+. This is a Symfony Mailer transport; it is ignored by Swift Mailer–based Laravel 8 and earlier.
Add the mailer in config/mail.php and verify the transport string against the package’s service provider/README:
'mailers' => [
'sendfox' => [
'transport' => 'sendfox',
'key' => env('SENDFOX_API_KEY'),
],
],
Set environment variables, including the new webhook secret if you want delivery feedback:
MAIL_MAILER=sendfox
SENDFOX_API_KEY=your-transactional-api-key
SENDFOX_WEBHOOK_SECRET=whsec_...
[email protected]
MAIL_FROM_NAME="Your App"
Also add sendfox.webhook_secret under config/services.php. The From address must be a verified SendFox sender/domain or sends fail with a 4xx transport exception.
Smoke-test the transport via Tinker or a temporary route:
Mail::raw('SendFox transport smoke test', function ($message) {
$message->to('[email protected]')->subject('SendFox test');
});
Confirm delivery and inspect the SendFox dashboard.
Smoke-test the webhook route. v0.3.0 registers POST /sendfox/webhook; make sure it is reachable (not behind auth, not CSRF-blocked) and that it survives route caching:
php artisan route:list --path=sendfox
curl -i -X POST https://your-app.test/sendfox/webhook -d '{}'
An unsigned request should be rejected — that is your proof the secret is wired up.
Where to look first:
Webhook::verify() — read the exact signed string and timestamp format before writing your own signing tests.SendFox client constructor (maxRetries) and paginate() for non-mail API use.MessageSending/MessageSent events and Dyaskur\SendFox\WebhookReceived for observability.Use a dedicated mailer, not a global default, when you have mixed providers.
Mail::mailer('sendfox')->to($user->email)->send(new WelcomeEmail($user));
Keep MAIL_MAILER on your normal provider and select sendfox explicitly, or vice versa. Routing password resets through a marketing sender is a common self-inflicted wound.
Standard Mailable usage works as expected — to/cc/bcc/replyTo, HTML + text() parts, attach()/attachData() all map into the SendFox payload. Build Mailables normally; keep SendFox-specific tweaks in a base Mailable or trait so a transport swap stays cheap.
Queue sends, and now pair ShouldQueue with an Idempotency-Key. This is the headline pattern change in v0.3.0. The transport adds an HTTP Idempotency-Key header so a retried queued job does not double-send:
class ReceiptEmail extends Mailable implements ShouldQueue
{
public int $tries = 3;
public array $backoff = [10, 60, 300];
public function __construct(public Order $order) {}
public function build()
{
return $this
->subject('Your receipt')
->view('emails.receipt')
->withSymfonyMessage(function (Email $message) {
$message->getHeaders()->addTextHeader(
'Idempotency-Key',
'receipt-'.$this->order->id // stable across retries
);
});
}
}
The key must be derived from business identity, not random — see the gotcha below.
Add tags and correlation headers via Symfony Email headers, since tags are not a native Mailable concept:
->withSymfonyMessage(function (Email $message) {
$headers = $message->getHeaders();
$headers->addTextHeader('X-SendFox-Tag', 'receipt');
$headers->addTextHeader('X-Correlation-ID', (string) Str::uuid());
})
Confirm exact header names, casing, and limits in the package tests. Use tags for operational grouping (receipt, password-reset), never for PII.
Handle webhooks: verify first, then queue the work.
Event::listen(function (WebhookReceived $event) {
if ($event->type === 'bounced') {
Bounce::firstOrCreate(['email' => $event->payload['email']]);
}
});
The package verifies the HMAC and dispatches WebhookReceived once per event, skipping SendFox retries of the same event ID. Still write your listeners idempotently (firstOrCreate, upserts) and keep them fast — dispatch a job for anything slow. Verify synchronously against the raw body; never re-encode $request->all() before checking the signature.
Close the deliverability loop. bounced/unsubscribed events now let you suppress recipients locally instead of assuming happy-path delivery. Mark the user/address, exclude it from future recipient lists, and alert on complaint spikes. Suppression state still lives in SendFox — mirror it, don’t replace it.
Tune retries deliberately. Defaults are 3 attempts, honouring Retry-After on 429 and retrying 5xx/network errors only on idempotent methods. Raise or disable per client:
$sendfox = new SendFox($key, maxRetries: 5);
$sendfox = new SendFox($key, maxRetries: 0); // fail fast, let the queue own retries
Pick one retry owner. Two layers multiply (3 queue tries × 3 transport retries = up to 9 attempts).
Use paginate() for contact/audience sync instead of hand-rolled loops.
foreach ($sendfox->paginate('/contacts', ['per_page' => 1000]) as $contact) {
// streamed page by page
}
It lazily fetches each page, so wrap it in a queued job and checkpoint progress if the dataset is large.
Test at three levels.
Mail::fake() + Mail::assertSent(ReceiptEmail::class); no API calls.Http::fake()/PSR-18 fake; assert payload, headers, tags, attachments, and the Idempotency-Key header.Http::fake(['*' => Http::sequence()
->push(['error' => 'rate limited'], 429, ['Retry-After' => '1'])
->push(['ok' => true], 200)]);
Multi-tenant / per-account sending still requires rebuilding config and purging the resolved mailer:
config([
'mail.mailers.sendfox.key' => $tenant->sendfox_api_key,
'mail.from.address' => $tenant->verified_sender,
]);
Mail::purge('sendfox');
Mail::mailer('sendfox')->to($user->email)->send(new NoticeEmail($tenant));
There is only one registered webhook route, so a per-tenant webhook secret must be resolved inside your handling (by inspecting the payload/route) rather than by config alone — plan for a single platform-level SendFox account if you can.
Observe sends via events. Listen to MessageSending/MessageSent for correlation IDs, recipients, tags, and timing; catch Symfony\Component\Mailer\Exception\TransportExceptionInterface for non-queued sends and log the response body, not just the message.
0.3.*, read the changelog before minor bumps, and keep transport-specific code behind your own base Mailable so header/route changes don’t ripple through the app.php artisan package:discover, config:clear, and route:clear before config:cache/route:cache in deploy scripts. A cached route table or stale config will silently drop /sendfox/webhook or leave webhook_secret null.$request->getContent(), and mirror Webhook::verify() exactly in your own tests rather than guessing the signed string.array cache driver in local/testing won’t dedupe at all, and a short TTL lets duplicates back in. Regardless, treat delivery as at-least-once and make handlers idempotent.Idempotency-Key must be stable, and that is easy to get wrong. Generating Str::uuid() inside build() produces a new key on every retry attempt, defeating the whole feature. Derive it from the domain (receipt-{$order->id}) or store it as a serialized Mailable property.POST that hits 5xx is not retried by the transport. That is deliberate (a blind resend risks duplicates) but it means the queue is still your only safety net for send failures. Conversely, a 429 without Retry-After means an exhausted monthly quota — retrying is pointless, so alert on it as a business failure and check plan limits before launch.Retry-After delays count against your worker timeout. A long backoff inside the HTTP client can outlive queue:work --timeout or your mailer’s connection timeout, killing the job mid-retry. Keep transport retries short and let the queue handle long delays.MAIL_FROM_ADDRESS and any Mailable from() must match a verified SendFox sender/domain; mismatches surface as 4xx transport exceptions.Idempotency-Key isn’t forwarded to delivered mail.cc/bcc into an API payload; confirm BCC recipients aren’t exposed in delivered headers before using BCC for compliance-sensitive flows.paginate() is a generator. Don’t iterator_to_array() a large contact list, don’t reuse an exhausted generator, and remember each page is a network call that can throw mid-iteration — checkpoint progress if you’re syncing.MAIL_MAILER=log/array locally and Mail::fake() in tests. For webhook work, use a tunnel (ngrok/Expose) with a development secret, and generate signatures in your own test helper.Mail::raw() fails, switch to a Mailable with both view() and text() — some API providers require an HTML part.maxRetries per client instance, override mailer config at runtime, listen to mail and webhook events, and wrap everything in your own service class. Keeping SendFox-specific headers, config, and webhook routing behind one adapter makes the next provider migration (or the next 0.x bump) much cheaper.How can I help you explore Laravel packages today?