bernskiold/laravel-postmark
A Laravel package for sending email through Postmark, wiring Postmark's API into Laravel's mail system with sensible configuration and a simple setup for apps that need reliable transactional delivery.
Architecture fit. This package is a thin, opinionated adapter between Postmark's webhook delivery and Laravel's event system. It occupies the correct layer: an authenticated HTTP ingress (Route::postmarkWebhooks() + VerifyPostmarkWebhook), a queue boundary (ProcessPostmarkWebhook with configurable connection and queue), and a typed domain surface (six events plus a RecordType enum). That is the right decomposition for a Laravel application — it keeps HTTP concerns out of business logic and gives consumers a stable event contract rather than raw payload arrays. If our application already uses Postmark for transactional email and Laravel events as its internal integration bus, fit is high and the package removes roughly the entire custom webhook controller we would otherwise write.
Integration feasibility. Low effort. The macro-based route registration means no route file surgery, middleware is pre-wired, and the six listed events cover the engagement and deliverability lifecycle most product teams actually act on (bounce suppression, complaint handling, subscription state, engagement scoring). The InactiveRecipients helper is a notable bonus: 406 inactive-recipient rejections are one of the more tedious Postmark failure modes to handle correctly, and having address extraction handled for us removes a class of silent send failures. Typed accessors for payload, metadata, client, and location reduce the "what shape is this JSON again" tax across every consumer.
Technical risk. Four areas warrant scrutiny before we commit:
RecordType enum connects each Postmark record type to its event, but only six events are shipped. Postmark also emits Inbound and SMTP API Error records. If the enum is exhaustive while the event set is not, unknown record types may resolve to no event, or throw. We need to confirm behavior for unmapped types — a thrown exception becomes a retried, then failed, job.EmailBounced or EmailComplained by mutating suppression state or firing notifications, duplicates must be idempotent at our listener layer. This is the single most likely source of a production incident.Key questions.
ProcessPostmarkWebhook dedupe or expose the Postmark MessageID + RecordType pair for consumer-side deduplication?api or web group, and is CSRF exclusion handled? What HTTP status does the middleware return on auth failure, and does that status cause Postmark to retry indefinitely?Event::fake() and assert dispatch, and is there an artisan publish step for config?Stack fit. Assumes PHP 8.2–8.4 and Laravel 11–13. Our target runtime should be PHP 8.3 or 8.4 on Laravel 11 or 12 — versions we can validate today. The package assumes a queue-backed deployment; any environment where QUEUE_CONNECTION=sync will execute webhook handling inline inside the HTTP request, which changes the failure surface materially (a slow listener causes Postmark to time out and retry). Queue infrastructure is therefore a hard prerequisite, not optional.
Migration path. Greenfield for us — no prior implementation to unwind. If we have an existing custom Postmark webhook controller, the migration is: stand up the package route on a parallel path, point a staging Postmark webhook config at it, verify event parity against the legacy controller, then cut the production webhook URL over. Because Postmark webhook configuration is a single URL per server/message stream, cutover is near-instantaneous and reversible — keep the old route live for one release cycle as a fallback.
Compatibility. Two dimensions to pin. Framework: constrain the Composer requirement to versions we test. Postmark: confirm the API version and webhook payload schema the package targets, and whether Postmark's newer record types are tolerated. Also confirm interaction with laravel/horizon if we use it — a dedicated queue name for ProcessPostmarkWebhook is the right default and should be set explicitly rather than left to the default connection.
Sequencing. (1) Publish and review config; confirm credential sourcing from secrets, never committed. (2) Register the route behind TLS termination only — Basic Auth credentials over plaintext are a non-starter. (3) Wire the job to a dedicated queue with its own worker pool so email event volume cannot starve transactional jobs. (4) Build listeners with idempotency keys derived from Postmark's message ID and record type. (5) Add a dead-letter path: failed jobs must land somewhere observable, not be silently discarded after retries. (6) Load-test with a realistic open/click volume before enabling Open and Click webhooks in production, since those are the highest-volume and lowest-value-per-event types.
Maintenance. Small surface area — one route, one middleware, one job, six events. The primary maintenance burden is not the package but the listeners we write on top of it. Watch for framework-version drift: the Laravel 13 claim means we should expect a compatibility release cadence and verify it ourselves rather than assume it. Treat the package as a dependency with a version pin and a scheduled review, not a set-and-forget.
Support. No prior assessment and no maintainer data in hand, so we are effectively adopting on the strength of the release notes alone. That argues for a bus-factor mitigation: keep the integration thin enough that forking or replacing the package with an in-house controller is a day of work, not a quarter. Avoid letting package-specific types leak broadly into domain code — normalize into our own event objects at the listener boundary so a swap is contained.
Scaling. Volume is driven by Postmark, not by us: recipients × event types. Opens and clicks dominate, and Apple Mail Privacy Protection inflates open counts substantially — plan queue capacity for opens first and consider not subscribing to Open/Click webhooks at all unless a product feature consumes them. A dedicated queue plus horizontally scaled workers is the right shape. The webhook endpoint itself is cheap; the queue depth is the metric to alarm on.
Failure modes. The dangerous ones are silent. (1) Misconfigured queue connection or a worker that is not running: events are accepted with a 200 and never processed — invisible data loss. (2) Credential mismatch: Postmark receives 401 and retries, then surfaces repeated failures; this must be alerted, because a rotated secret silently stops all email event processing. (3) Listener exceptions: jobs retry and then fail; without a dead-letter destination the events vanish. (4) Duplicate delivery causing double side effects (double suppression, double notifications) — mitigated only by our idempotency layer. (5) Unmapped record types throwing. Each of these needs an explicit alert or a durable capture sink before go-live.
Ramp-up. Low for a Laravel developer — under a day to a working integration, given good documentation. The real ramp-up cost is downstream: product and data teams need to agree on what each event means for suppression, notification, and analytics before listeners are written. Budget the design conversation, not the wiring.
How can I help you explore Laravel packages today?