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.
Scope of this review: This supersedes the v0.1.0 baseline. v0.3.0 (reached via v0.2.0) adds four capabilities that materially change the risk profile: signed webhooks with replay suppression, bounded automatic retries, Idempotency-Key propagation in the mail transport, and cursor-style pagination on the API client. Three of the ten open questions from the baseline (error mapping, internal 429 handling, delivery feedback) are now answered in the code's favour; one new inbound attack surface and one new operational dependency have been introduced.
Architecture fit
TransportInterface plus a Laravel service provider registering an auto-discovered sendfox mailer. For apps already sending through Mail, Notifiable, or Mailable, integration remains configuration-level.POST /sendfox/webhook itself and dispatches WebhookReceived. This is qualitatively different from a transport: it introduces a public, unauthenticated-at-the-router HTTP endpoint into our application, plus a persistent dedupe store. It belongs in the architecture review, not just the mail config review.X-SendFox-Signature, plus a ±5-minute timestamp window and per-event-ID suppression of provider retries. That is the correct shape. The residual questions are where the dedupe state lives and whether it is safe under multiple app instances.new SendFox($key, maxRetries: 5), paginate()) widens the package's remit from "mail transport" to "SendFox API client." That is useful — contact/audience sync is now plausible without a second SDK — but it also means the package becomes a dependency of data-sync jobs, not only of mail.Integration feasibility
Idempotency-Key is the single highest-value addition for us. It converts at-least-once queue semantics into effectively-once delivery for the provider, which was previously an unavoidable duplicate-send risk on any retry. Realising the benefit requires us to set a deterministic key per logical message (e.g. derived from the mailable class + a business key such as order ID), not a random UUID per attempt — a naive implementation gets the API call but not the guarantee.429 with Retry-After is honoured, 5xx/network errors retried on idempotent methods, retry count tunable, retries disableable. This is better than most pre-1.0 packages.Technical risk
new SendFox($key, maxRetries: 5) is an example of the kind of surface that can shift. Pin exactly; keep the mailer name and any webhook path behind config.tries, extend delivery latency unpredictably. Retry budget must be reasoned about end-to-end, not per layer.429 without Retry-After (monthly quota consumed) fails fast. Correct, but it means quota exhaustion manifests as a wave of hard failures in failed_jobs — it needs its own alert, not just generic send-failure alerting.POST + 5xx is not retried. Because POST is non-idempotent by default, the client declines to retry it. Sensible, but it means a transient provider-side 5xx on a send fails the job even when we supplied an Idempotency-Key that would have made the retry safe. Whether the transport leverages the key it forwards is an open question and a candidate improvement.bounced handling. Must be confirmed as Redis/DB-backed and shared.bounced appears in the example. Whether delivered/opened/complaint/unsubscribed exist, and whether bounce events distinguish hard vs soft, determines whether this genuinely replaces a bounce-monitoring path or only gestures at one.iterator_to_array() it will still blow up on large contact lists.Key questions
Resolved since baseline: error/exception mapping (retries now explicit per status class), internal 429 handling (yes, when Retry-After present), delivery feedback (partially — webhooks).
^6 vs ^7? Does the webhook route/provider work on our exact Laravel version?/sendfox/webhook is a fixed literal in the notes)? Can it be prefixed or disabled per environment (e.g. off in local/CI)?POST when an Idempotency-Key is present? If not, is that on the roadmap? A transient 5xx on a send is the most common failure and currently fails the job outright.Idempotency-Key passed through verbatim, or transformed/normalised? What is the accepted length/charset, and does it dedupe against a TTL we should know about?WebhookReceived expose the raw payload plus typed accessors, and is the payload persisted anywhere by default (data-retention implications for recipient PII)?paginate() limits: max per_page, total-item behaviour on very large lists, and whether it is a generator (memory-safe) or an array.services.sendfox.webhook_secret implies one account. How do we run two SendFox accounts (multi-brand) — separate secrets, separate routes?Retry-After semantics, per-message attachment limits, and whether quota is shared with campaign sending.Mail::fake(), and does the webhook route stay inert in tests?Stack fit
Mail. Neutral for non-Laravel Symfony. Poor fit where provider-side templating or scheduling is required.Migration path
sendfox as a non-default mailer; keep the incumbent configured and credentialed; keep the mailer name in config, not in code.TransactionalMailer resolver (or mail.transactional.driver) so provider is switchable per environment and per mail type. With webhooks in play, the resolver must also gate event handling per provider, otherwise a driver switch leaves an orphan listener.Idempotency-Key from day one of the PoC, using a deterministic business key. Retrofitting this later means auditing every send site.SENDFOX_WEBHOOKS_ENABLED) and for package retries (maxRetries: 0) so we can degrade without a code change.Compatibility
composer.json constraints — three minors in, the Symfony Mailer constraint is the most likely breakage point, and the webhook route registration is a new surface that version skews can break silently./sendfox/webhook route, a catch-all, or a route-cache (route:cache) interplay. Auto-registered routes plus cached routes is a classic production-only surprise.VerifyCsrfToken exemption and that no global middleware (body-parsing, JSON re-encoding, request logging that consumes the stream) sits between the router and the signature check.Mail::fake() bypass intact, webhook route not calling out, signature verification unit-testable with a fixed secret.Sequencing
POST retry consults the idempotency key.Idempotency-Key, then deliberately retry the job and confirm single delivery.429 with and without Retry-After, forced 5xx on send, network timeout. Confirm failed_jobs observability and that quota exhaustion is distinguishable from transient failure.WebhookReceived, take no action) to validate volume, event types, and dedupe behaviour against real traffic.Maintenance
Support
Scaling
Failure modes
Retry-After): not retried by design, so it surfaces as a batch of hard job failures. Needs a dedicated alert distinguishing it from transient errors, or it will be misdiagnosed as a provider outage.Idempotency-Key. If our send sites do not set the key, queue retries risk duplicate emails.Ramp-up
0.3.0 remains pre-1.0 with no stated support policy, so Tier-1 transactional traffic should still wait for version 1.0, a documented support commitment, or a clean proven canary, whichever comes first.How can I help you explore Laravel packages today?