Download the PHP package zain-ul-abdain/laravel-webhook-ledger without Composer

On this page you can find all versions of the php package zain-ul-abdain/laravel-webhook-ledger. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package laravel-webhook-ledger

Laravel Webhook Ledger

Exactly-once webhook processing for Laravel. Signature verification, atomic deduplication, and a durable event log — for Stripe, PayPal, and anything else that posts JSON at you.

tests


The problem

Every payment provider will deliver the same webhook to you more than once. Not occasionally — routinely. They retry on timeout, on a 500, on a connection reset, and sometimes on a response they simply didn't like. Stripe retries for up to three days. PayPal does too.

If your handler credits an account, ships an order, or releases a payout, running it twice is not a cosmetic bug. It is money.

So most codebases reach for the obvious guard:

Why that doesn't work

There is a gap between the exists() check and the create(). Under concurrent delivery — which is exactly what a retry storm produces — two requests can both execute the check before either executes the insert:

The window is small. It is not small enough. Any provider retrying an event a few hundred milliseconds apart will find it, and you will discover this the way everyone discovers it: from a customer, about a double refund.

The only component that can arbitrate this is the database. So this package lets it:

and then treats the constraint violation as the duplicate signal rather than as an error:

There is no window, because there is no gap. The insert either succeeds or it doesn't, and the database decides atomically.


Usage

The closure runs at most once per event, across concurrent requests, across queue workers, and across redeliveries days apart. Everything else — verification, deduplication, persistence, failure recording — happens around it.

Always respond 200 to a duplicate. Returning an error makes the provider retry an event you have already handled, which is how one delivery hiccup becomes a retry storm.

Configuration

paths are dot-paths into the payload. A list of candidates is tried in order — useful when a provider moves a field between API versions and you're receiving both.

Running two Stripe accounts (a second region, or a platform account for Connect)? Give each its own provider entry. They have different signing secrets, and sharing one entry would silently accept events signed by either.


What else it handles

Signature verification comes first

Verification happens before any database write. An unauthenticated caller must not be able to create rows in the table your entire correctness guarantee depends on — that's a free denial-of-service against your own deduplication.

Three verifiers ship with the package:

Verifier For
StripeSignatureVerifier Stripe's t=…,v1=… scheme, implemented directly — no SDK dependency
HmacSignatureVerifier The common case: HMAC(body, secret) in a header, with configurable algorithm and prefix
SharedSecretVerifier A static token in a header. Weak, but it's what some providers offer

All comparisons use hash_equals, so timing doesn't leak how much of a signature was correct.

The Stripe verifier enforces a timestamp tolerance. Without it, anyone who captures one validly-signed request can replay it forever.

A note on raw bodies. Signatures are computed over the exact bytes the provider sent. Never reconstruct the body with json_encode(json_decode($body)) — key order, whitespace, unicode escaping, and float formatting all drift, and your verification will fail intermittently in ways that are genuinely unpleasant to debug.

Providers that send no event id

Some don't. The ledger falls back to a content fingerprint — sha256(provider + raw body) — which deduplicates identical redeliveries correctly.

Know the limitation: two genuinely distinct but byte-identical events collapse into one. That's the right trade for retries and the wrong one for, say, a ping sent twice on purpose. Use a real event id wherever the provider offers one.

Workers that die mid-handler

A row is claimed as processing before the handler runs. If the process is killed at that moment — deploy, OOM, timeout — the row is stuck: nothing completes it, and the unique index rejects every redelivery. The event is now permanently lost.

So a claim expires. After stale_claim_after seconds, the next redelivery takes it over and increments attempts:

Set it comfortably above your slowest handler. Too low risks two workers running the same handler concurrently; too high leaves crashed events waiting.

For events the provider never redelivers, a scheduled sweep converts stuck claims into failures you can see and replay:

Events that arrive before you're ready

Providers can outrun your own writes. Stripe will happily deliver checkout.session.completed before your redirect handler has committed the local order row — so the handler has a perfectly valid event and nothing to apply it to.

That is neither success nor failure. Recording it as success loses the event; recording it as failure fills your alerts with something that resolves itself. So say so:

The event is stored with status deferred and left retriable — the provider's next redelivery runs the handler again, and providers retry for days. Respond 200 either way; $result->wasDeferred() tells you which happened.

A deferred retry goes through the same atomic claim as a stale-claim takeover, so two simultaneous redeliveries can't both pick it up.

Linking an event to your own records

subject_type / subject_id are indexed, so "every event we ever received about this order" is a lookup rather than a scan through stored payloads — which is exactly the question you want answered when someone disputes what happened.

Failures are explicit, not silent

A handler that throws marks the event failed and rethrows. The exception is yours to log, alert on, and handle.

Redelivery of a failed event is treated as a duplicate, not a retry. Retrying is a deliberate, observable act — not something that happens to occur because the provider happened to try again:

Replay needs a handler it can invoke without an HTTP request, so add an invokable class to that provider's config:

Events

WebhookProcessed, WebhookDuplicateDetected, and WebhookFailed are dispatched for observability.

Duplicates are normal traffic — every provider redelivers. Worth a counter; not worth an alert unless the rate jumps.


Schema

The stored payload is worth as much as the deduplication. When a customer disputes what happened, the raw event as the provider sent it is the only record that settles it.


Testing

28 tests covering concurrent redelivery, tampered and replayed signatures, secret rotation, fingerprint fallback, stale-claim takeover, deferral and retry, subject linking, and failure recording.

The suite runs against SQLite, PostgreSQL and MySQL, because the deduplication guarantee rests on constraint-violation behaviour and that differs by engine — PostgreSQL aborts the enclosing transaction where the others don't. A SQLite-only suite cannot establish that the duplicate path is correct.

If you have Docker, all three run without a local PHP install:

Separately, an 11-test integration suite runs against a real Laravel application with the package installed from Packagist — covering service-provider discovery, migrations landing in the host app, signature rejection, deduplication, deferral and subject linking. Testbench can't prove those; a real app can.


Requirements

PHP 8.2+ · Laravel 12 or 13 · any database with unique constraint support

Laravel 11 is not supported: every 11.x release currently carries open security advisories, so Composer refuses to install it under default policy.

License

MIT. Built by Zain Ul Abdain — backend engineer working on payments infrastructure.

Portfolio · GitHub · LinkedIn


All versions of laravel-webhook-ledger with dependencies

PHP Build Version
Package Version
Requires php Version ^8.2
illuminate/contracts Version ^12.0|^13.0
illuminate/database Version ^12.0|^13.0
illuminate/support Version ^12.0|^13.0
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package zain-ul-abdain/laravel-webhook-ledger contains the following files

Loading the files please wait ...