webgriffe/payum-lock-request-extension
Payum extension for PHP that locks payment requests to prevent duplicate or concurrent processing. It wraps gateway actions with a lock, guarding capture, authorize, and status calls against double submits and race conditions. Registers as a standard Payum extension; no gateway changes needed.
Install and inspect
composer require webgriffe/payum-lock-request-extension
Before wiring anything, open the repository and read, in this order:
README.md — it is the only documentation and is short.composer.json — check the Payum and symfony/lock version constraints; this tells you immediately whether it will install alongside your current stack.src/ — there is normally a single extension class (e.g. LockRequestExtension) plus maybe a key-generator. Read the constructor signature: it almost certainly takes a Symfony\Component\Lock\LockFactory (or a LockInterface/store) and a TTL.Minimal wiring
Payum extensions are registered either through the builder or through the container.
Builder style (framework-agnostic):
use Payum\Core\PayumBuilder;
$payum = (new PayumBuilder())
->addDefaultStorages()
->addGateway('stripe', [...])
->addExtension(new LockRequestExtension($lockFactory, 30)) // signature per README
->getPayum();
Symfony style — tag the extension so Payum's compiler pass picks it up:
services:
Webgriffe\PayumLockRequestExtension\Extension\LockRequestExtension:
arguments: ['@lock.factory', 30]
tags: ['payum.extension']
First use case
The one scenario worth adopting this package for: a customer double-clicks "Pay", or a gateway fires the same notify webhook twice, and Payum's CaptureAction / NotifyAction runs twice against the same Payment or Token. The extension wraps those executions in a lock keyed off the request, so the second execution either waits or bails out instead of hitting the gateway twice.
Try it on a single capture flow first, in a test environment, with a Redis or Doctrine lock store. Do not roll it out to all gateways on day one.
1. Scope the lock to the logical operation, not the gateway
A lock keyed on the gateway name will serialise every customer's payments. A lock keyed on the token hash or the payment ID serialises only the duplicate attempts for one checkout. Check how the extension derives its key; if it only accepts the token, that is usually fine, because Payum tokens are per-payment-session. If you need something finer, subclass the extension and override the key-generation method rather than reimplementing the lock logic.
2. Guard capture against double submission
The canonical pattern is: extension sits in the Payum stack, the second Capture request for the same token fails to acquire the lock, and you render a "payment already in progress" page instead of a gateway error. Catch the specific exception the extension throws (read the source — it is likely a LockAcquiringException from symfony/lock or a package-specific one) at the controller level and convert it into a user-facing response.
3. Deduplicate webhooks at the notify boundary
Gateways retry notifications aggressively. Registering the extension and letting it lock the notify request gives you at-least-once-to-at-most-once semantics for free, provided the lock key is the notification's identity (token hash is usually sufficient because Payum maps notifications to a token). Combine with a persisted notified_at timestamp or a Status::CAPTURED check — the lock only protects the window during execution, not what happens after.
4. Tune TTL to your slowest gateway call
The TTL must exceed your worst-case gateway HTTP timeout plus any queueing, or a slow-but-successful capture will have its lock expire mid-flight and a retry will slip through. Typical values are 30–60s. If you have synchronous calls that occasionally take 90s, either raise the TTL or move capture to a queue and lock at the job level.
5. Multi-server setups need a shared store
flock, SemaphoreStore, and FlockStore are single-host only. If you run more than one PHP container, use RedisStore, PdoStore/DoctrineDbalStore, or PostgresStore configured through symfony/lock. This is the single most common production mistake with this style of extension.
6. Combine with gateway-side idempotency
Locking prevents concurrent duplicate execution; it does not make a retry after a failed lock acquisition idempotent. Where the gateway supports idempotency keys (Stripe, Adyen), still pass one. The extension is a concurrency guard, not a substitute for server-side idempotency.
7. Testing pattern
In tests, swap the lock factory for one backed by InMemoryStore or MockStore, and assert that:
Capture calls both succeed (lock released correctly),try/finally, or add it if it does not).If the extension does not release on exception, that is a fork-worthy bug — the lock will hold until TTL expiry and block genuine retries.
Vendor lock-in risk is real. One star, zero dependents, last release October 2023. There is no community to answer questions and no guarantee of a PHP 8.4 / Symfony 8 release. Before adopting, pin the version in composer.json, read every line of src/ (it is small — that is the upside), and be prepared to fork. The logic is simple enough that a 60-line in-house extension using symfony/lock is a legitimate alternative if the package's constraints block your upgrade path.
Not-acquired is an error path, not a silent skip. Depending on configuration the extension will either block until TTL or throw immediately. Decide which you want per gateway. For webhooks, blocking briefly is often better; for user-facing capture, failing fast with a friendly message is better. The behaviour is usually controlled by the lock's acquire() call — check whether the package exposes a blocking flag or hardcodes one.
Key stability matters. If the key includes a timestamp, a random nonce, or an unserialised object, the lock will never collide and you get no protection. Confirm the key is a deterministic hash of a stable identifier (token hash, payment number). Log the key at debug level during rollout so you can see it is stable across duplicate requests.
Extension ordering. Payum runs extensions in registration order. If another extension (logging, gateway-config, encryption) throws before yours runs, or short-circuits the request, the lock may be acquired after the dangerous work or never released. Register the lock extension early and verify order in the compiled container.
Long-running or queued jobs. If capture is dispatched to a queue and the extension is registered on the Payum stack, the lock is held inside the worker. TTL expiry plus worker restarts can leave you with either a premature release or a stale Redis key. Keep TTLs short and prefer a lock key scoped to the job, or use symfony/lock's expiring / auto-release semantics deliberately.
Debugging. When "the payment got captured twice", the failure is almost always lock store configuration, not the extension. Check, in order: is the store shared across hosts? Is the key actually identical between the two requests? Did the first request's lock TTL expire before the second request started? Add a monolog handler on Symfony\Component\Lock at debug level and log the computed key plus acquire/release timestamps.
Extension points. The package is small; the intended extension point is subclassing the extension (overriding key generation or the acquire/fail behaviour) rather than configuration. Treat any constructor argument as part of the public surface and wrap your subclass in a thin adapter if you expect the upstream API to drift.
Do not lock the entire gateway stack. If you find yourself registering this globally with a gateway-name key to "be safe", you have built a bottleneck. Lock per token, per operation.
How can I help you explore Laravel packages today?