goldnead/statamic-payments
Goldnead Statamic Payments is a Statamic addon for handling payments in your site, letting you integrate checkout flows and payment providers with your content. It aims to simplify common payment tasks for Statamic developers.
Architecture fit
Checkout::start()), provider communication (MollieGateway::fetch()/createPayment()), and webhook ingestion. Treat it as tier‑1 infrastructure, not a convenience wrapper.checkout_key with a unique index, provider-level Idempotency-Key, and typed failure signalling (ProviderUnavailable). That is the right direction — it aligns with how Mollie itself expects to be integrated (idempotency keys + webhook redelivery).lockForUpdate() alone (a no-op on SQLite) and instead uses a database constraint as the source of truth. Constraint-based concurrency rather than application-level locking is the more defensible pattern for a package that must run on MySQL, Postgres, and SQLite.Integration feasibility
payments.checkout_key column (nullable, unique) and the extra idempotencyKey key in the createPayment() payload.PaymentGateway implementations are the main feasibility unknown. Implementers that forward the payload verbatim into a provider request body will now leak idempotencyKey into the body of a third-party API, which typically yields a 400 and a failed payment. Implementers with strict payload validation may throw. This is a contract change delivered in a minor version — technically semver-compatible, practically a breaking change for that subset.Technical risk
ProviderUnavailable condition that outlives that window (extended Mollie outage, expired credentials, DNS/TLS fault, self-inflicted 429 loop) leaves paid-but-unfulfilled orders with no further automatic signal. The 503 fix converts a silent data-integrity bug into a bounded, but still real, stranded-order risk.fetch() is being throttled (429) and the webhook answers 503, Mollie retries, generating more calls to a provider that is already refusing them. Absent explicit backoff/jitter and a circuit breaker inside fetch(), the failure mode is self-amplifying within the retry budget.payments is required. Laravel's default index creation is not concurrent on Postgres (CREATE INDEX takes an ACCESS EXCLUSIVE-adjacent lock and blocks writes) and on MySQL takes a brief metadata lock even with ALGORITHM=INPLACE. On a large payments table this is a write stall on the money path.Key questions
fetch() apply bounded retry with exponential backoff and jitter before throwing ProviderUnavailable, or does a single 429 immediately become a 503 webhook response?checkout_key canonicalised, and is there a supported way to force a new payment for a legitimate repeat purchase inside the ten-minute window?idempotencyKey additive-only, and does the package validate or ignore unknown keys for existing custom gateways? Is there a changelog entry aimed explicitly at PaymentGateway implementers?Stack fit
QueryException codes differ across drivers).ProviderUnavailable, and a scheduler for reconciliation.Migration path
PaymentGateway implementation and classify each as (a) ignores unknown payload keys, (b) forwards payload into a request body, (c) validates payload shape strictly. Group (b) and (c) need code changes before deploy.payments on the actual engine and version. If Postgres and the table is large, replace the generated index with CREATE UNIQUE INDEX CONCURRENTLY in a hand-written migration and mark the standard one as skipped. If MySQL and the table is very large, plan a gh-ost/pt-osc run or a low-traffic window.checkout_key; old code writes NULL. A nullable unique index tolerates multiple NULLs on MySQL, Postgres, and SQLite, so migration-before-deploy is safe in both directions — but code-before-migration is not. During a rolling deploy, mixed old/new pods can both create payments for the same basket; dedupe only takes effect once all pods run the new code. Confirm this is acceptable for the rollout window or gate checkout during the transition.ProviderUnavailable (block egress to the Mollie API, or point at an unreachable host in staging) and assert: the endpoint returns 503, the response is not intercepted or rewritten, the response is not cached, and fulfillment is not marked complete. Then restore connectivity and confirm redelivery produces exactly one fulfillment.Checkout::start() calls with identical baskets) asserting one row, one provider payment, identical checkout URL to both callers. Then a "same basket, 11 minutes later" test asserting a new payment is created — that is the escape hatch for repeat purchases and must be proven, not inferred.Compatibility
NULL key), but they are invisible to the new dedupe logic. Historic duplicate-prevention coverage starts at deploy time.Sequencing
Pin → gateway audit → staging migration rehearsal with prod-sized data → observability and alerting in place (503 rate,
ProviderUnavailable, unfulfilled-paid orders) → migrate production in a low-traffic window → deploy application code → verify webhook 503/redelivery in production with a single test transaction → enable reconciliation sweep → remove any interim manual workarounds.
Do not deploy the code change and the alerting change in separate weeks; the 503 behaviour is only operationally safe once someone is watching for it.
Maintenance
PaymentGateway-contract changes specifically.checkout_key derivation (canonicalisation rules) is now business logic owned by this integration. If the rules are wrong, the bug is yours, not the vendor's.Support
Scaling
Failure modes
| Mode | Signal | Consequence | Mitigation |
|---|---|---|---|
| Provider unavailable, transient | 503 on webhook, ProviderUnavailable logged |
Order delayed, redelivered within ~26h | Alert on 503 rate; confirm queue drains post-incident |
| Provider unavailable, sustained >26h | Redelivery stops, order stays pending | Paid, unfulfilled, silent | Scheduled reconciliation sweep against Mollie; alert on pending-but-paid age |
| Throttling loop (429 → 503 → retry) | Rising 429s and 503s together | Amplified load, extended outage | Backoff/jitter and circuit breaker inside fetch() |
| Canonicalisation mismatch | Double-click still produces two payments | Original bug survives | Explicit concurrency test in CI; monitor for duplicate-customer-payment patterns |
| False-positive dedupe | Repeat purchase returns stale checkout URL | Lost sale, confused buyer | Escape hatch for forced new payment; document the 10-minute rule |
| Duplicate-key exception unhandled | 500 on double-click | Support tickets, abandoned carts | Verify the losing request returns the winner's URL, in a test |
| Migration lock | Write stall on payments during deploy |
Checkout unavailable | Rehearse on prod-sized data; use concurrent index creation where supported |
| Proxy/middleware rewrites status | Webhook always 200 in logs | Silent reintroduction of the original defect | End-to-end assertion of the 503 on the real ingress path |
Ramp-up
How can I help you explore Laravel packages today?