Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Postmark Laravel Package

bernskiold/laravel-postmark

View on GitHub
Deep Wiki
Context7

Getting Started

Install the package and publish its config:

composer require <vendor>/laravel-postmark-webhooks
php artisan vendor:publish --tag="postmark-webhooks-config"

Set the HTTP Basic Auth credentials in .env — these are the same username/password you enter in Postmark's webhook settings (Server → Webhooks). The package's VerifyPostmarkWebhook middleware rejects anything that doesn't match, so the endpoint is authenticated before your code ever runs.

Register the endpoint. Put it in routes/api.php (stateless, no CSRF) unless you have a reason not to:

// routes/api.php
Route::postmarkWebhooks('/webhooks/postmark');

Then point Postmark at https://your-app.test/api/webhooks/postmark, tick the record types you care about, and set the Basic Auth credentials there.

First use case: listen for EmailBounced and suppress the address. This is the single highest-value thing the package does — it turns Postmark's async bounce notifications into a normal Laravel event you can act on:

Event::listen(EmailBounced::class, function (EmailBounced $event) {
    Suppression::firstOrCreate(['email' => $event->payload()->Email]);
});

Once that works, verify it end-to-end: Postmark's webhook screen has a "Send test" button per record type, and php artisan queue:work must be running for anything to fire (see Gotchas).

Implementation Patterns

Listen to events, don't touch the job. ProcessPostmarkWebhook exists to translate a request into an event; treat it as infrastructure. All of your logic belongs in listeners for EmailBounced, EmailDelivered, EmailOpened, EmailClicked, EmailComplained, EmailSubscriptionChanged. Laravel 11+ auto-discovers listeners in app/Listeners, so most projects need no registration at all.

Use the typed accessors, not raw array access. Each event exposes typed methods for payload, metadata, client and location rather than making you dig through $event->payload['Details']. Prefer those — Postmark's payload shape varies meaningfully per record type, and the typed methods are the package's contract with you.

Route queueing deliberately. ProcessPostmarkWebhook is queued and you can set its connection and queue — do that if you want webhook processing isolated from your default queue (e.g. a dedicated postmark queue with its own worker, so a spike of opens doesn't starve your mail-sending jobs). Note that if your listeners also implement ShouldQueue, they get queued a second time from inside the job; usually you want listeners to be synchronous and let the job provide the queue boundary.

Use RecordType for catch-all handling. The enum maps each Postmark record type to its event class, which is useful for logging or metrics in one place rather than six listeners:

foreach (RecordType::cases() as $type) {
    Event::listen($type->event(), fn ($e) => Metrics::increment($type->value));
}

Handle the outbound side with InactiveRecipients. Bounces are async, but Postmark also rejects sends synchronously with a 406 "inactive recipient". Wrap your send calls and use the helper to detect that specific rejection and pull the offending addresses out of it — then suppress them the same way your EmailBounced listener does. This closes the loop: addresses get suppressed whether Postmark tells you at send time or hours later.

Multiple endpoints and mail streams. Nothing stops you registering several routes (transactional vs. broadcast stream) with different listeners, or registering one route and branching on the message stream in a listener. If you only care about bounces and complaints, configure only those record types in Postmark — fewer events, less noise, less queue volume.

Testing. Queue::fake() + posting a signed request to the route is the honest test; Event::fake() + dispatching the job directly is the fast one. Do at least one integration test that posts a real captured payload through the route with correct Basic Auth and asserts the event fires — that's the seam most likely to break on upgrade (middleware ordering, route registration, credentials).

Gotchas and Tips

CSRF will silently kill you in routes/web.php. The middleware authenticates the request, but Laravel's VerifyCsrfToken runs first and Postmark sends no token — you get a 419 and Postmark retries and eventually disables the webhook. Use routes/api.php, or explicitly exclude the URI from CSRF.

Nothing happens without a queue worker. The route returns 200 as soon as the job is dispatched, so Postmark looks happy while your events never fire. If your queue is down, webhooks accumulate in jobs and eventually failed_jobs. Check those tables first when "webhooks aren't working."

Treat handlers as idempotent. Postmark retries on non-2xx and can deliver the same record more than once. Deduplicate on MessageID + record type, or write listeners that are naturally idempotent (updateOrCreate, not create). A listener that throws will cause a retry loop against your endpoint.

Basic Auth edge cases. The credentials must match exactly, including a non-empty password — some HTTP clients mangle empty passwords, and Postmark's config UI doesn't always make it obvious which field is which. If you're behind a proxy or load balancer that strips the Authorization header, the middleware will reject everything and the failure looks like an auth bug in the package.

Nullable fields are normal. Location and client info are absent for plenty of opens and clicks, Metadata is empty unless you set it on send, and subscription-change payloads differ from bounce payloads. Null-safe access on the typed methods; don't assume a field exists because it appeared in your one test payload.

406 ≠ bounce, and bounce ≠ complaint. A 406 is a send-time refusal (address suppressed at Postmark). EmailBounced is an async DSN. EmailComplained is a spam report — arguably more serious, and a different action (usually permanent suppression + list hygiene). Don't collapse them into one handler.

Pin your expectations against the version constraints. PHP 8.2–8.4 and Laravel 11–13 means this won't install on a Laravel 10 app — if you're mid-upgrade, this package is a reason to finish it, not something to work around.

Extension points: bind a subclass of ProcessPostmarkWebhook in the container if you need to change dispatch behaviour, and use the RecordType enum for forward-compatible dispatch. Unknown record types are the realistic upgrade hazard — handle the default case rather than assuming the enum covers everything Postmark can send, so a new Postmark record type doesn't throw inside your webhook path.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log
gregsphotography/site-builder-components
io-digital/clickatell
snowsoft/laravel-admin
oxygen/auth
relicsoftware/barakah-payment-gateway
tobya/work-with-composer
equipc/laravel-email-validator