Adds Quickpay as a payment gateway to your Sylius store. The plugin integrates the
setono/payum-quickpay Payum gateway into Sylius' checkout,
state machine, and admin.
- PHP 8.1 or higher
- Sylius 1.x on Symfony ^6.4 (tested against Sylius 1.14)
- PSR-17 factories discoverable by
php-http/discovery(e.g.nyholm/psr7) — the plugin itself bringssymfony/http-clientas its PSR-18 client
The plugin 2.x line and the setono/payum-quickpay gateway it builds on are still in pre-release,
so your project must allow the pre-release versions explicitly:
composer require setono/sylius-quickpay-plugin:^2.0@alpha setono/payum-quickpay:^2.0@RC setono/quickpay-php-sdk:^1.2If your project does not already provide PSR-17 factories:
composer require nyholm/psr7<?php
# config/bundles.php
return [
// ...
Setono\SyliusQuickpayPlugin\SetonoSyliusQuickpayPlugin::class => ['all' => true],
];The state machine callback that captures, refunds, and cancels Quickpay payments when the corresponding Sylius payment transitions are applied (see How it works) is registered automatically. Each operation can be turned off individually:
# config/packages/setono_sylius_quickpay.yaml
setono_sylius_quickpay:
operations:
capture: true # forward the payment's complete transition to Quickpay as a capture
refund: true # forward the refund transition to Quickpay
cancel: true # forward the cancel transition to Quickpay
fraud:
block_capture: false # skip the automatic capture when Quickpay reports the payment as fraud suspected (see Fraud signals)The plugin hooks into both state machine adapters supported by Sylius 1.14 — a winzou_state_machine
callback (the default adapter) and a Symfony workflow event subscriber — so the operations are forwarded to
Quickpay no matter which adapter your application runs the sylius_payment graph on.
# config/routes/setono_sylius_quickpay.yaml
setono_sylius_quickpay:
resource: "@SetonoSyliusQuickpayPlugin/Resources/config/routes.yaml"This registers the callback endpoint (POST /payment/quickpay/notify) that receives the callbacks of operations
made outside your store — in the Quickpay manager, or by anything else talking to the API. Everything the store
itself does is confirmed without configuration; see Callbacks.
bin/console assets:installThe plugin ships the payment method logos shown on the checkout (see
Payment method logos on the checkout); like every bundle asset they are
published to public/bundles/setonosyliusquickpayplugin/ by assets:install.
# config/packages/setono_sylius_quickpay.yaml
imports:
- { resource: "@SetonoSyliusQuickpayPlugin/Resources/config/app/fixtures.yaml" }The fixtures create a Quickpay credit card payment method and matching channels. They read the gateway credentials from these environment variables:
QUICKPAY_API_KEY=
QUICKPAY_PRIVATE_KEY=
QUICKPAY_AGREEMENT_ID=
QUICKPAY_ORDER_PREFIX=qp_Create a new payment method of type Quickpay in the admin panel (Configuration → Payment methods) and fill out the gateway configuration:
| Field | Description |
|---|---|
| Api key | The API key of the API user in your Quickpay manager (Settings → Users) |
| Private key | The private key of your merchant account (Settings → Integration) |
| Agreement id | (optional) Which agreement the payment window runs on. Leave empty to use your account's default Payment Window agreement — set it only if your account has several agreements and the window should use a specific one |
| Order prefix | Prepended to order numbers sent to Quickpay as the order_id — must be unique per project and environment sharing the same Quickpay account (see Troubleshooting), and 11 characters or less |
| Payment methods | Which payment methods the Quickpay payment window offers, e.g. creditcard or mobilepay — see the Quickpay documentation |
| Capture mode | When the money is taken. On completion (default) only authorizes the payment at checkout and captures it when the payment is completed in Sylius (e.g. when the order is shipped) — what shops that may not capture before dispatch need. Immediately lets Quickpay capture the moment the card is authorized — useful for digital products |
| Synchronized operations | Run capture, refund and cancel synchronously instead of relying on the Quickpay callback |
| Branding id | (optional) The payment window branding to use |
When you save the payment method, the plugin verifies the API key against Quickpay's API and rejects the
form if Quickpay rejects the key — a typo'd key is caught immediately instead of by the first customer
whose checkout fails. A valid key whose API user lacks the /ping permission is verified through a
/payments read instead, so a locked-down API user never blocks saving; neither does an unreachable
Quickpay — the check fails open on anything but an explicit rejection.
- During checkout the customer is redirected to the Quickpay payment window through a payment link. What happens to
the money is the payment method's capture mode: on completion (the default) only authorizes the payment
and the plugin captures it when the payment is completed in Sylius; immediately has Quickpay capture the
moment the card is authorized. Under the hood the mode is Sylius core's
use_authorizegateway option — the plugin runs Payum'sAuthorizeorCaptureaccordingly, which is how the gateway library expresses the two flows since 2.0. - Quickpay notifies your store of payment changes through callbacks on a per-payment url the gateway mints — for
the payment window's outcome and for every capture, refund and cancel the store issues (the gateway names that
url on each operation). Only operations made outside the store need the account-wide callback url (see
Callbacks). Every callback's
Quickpay-Checksum-SHA256header is validated against your private key before anything about the payment changes. A genuine callback then reconciles the payment: Sylius applies the state change its own mapping gives for the Quickpay payment's status (authorized, completed, refunded, ...), at most once. - When you complete, refund, or cancel a payment in the Sylius admin, the plugin performs the matching capture, refund, or cancel operation against Quickpay. A failed cancel at Quickpay (e.g. the customer never completed checkout, so there is nothing to cancel) is logged but does not block cancelling the order.
- A refund targets Quickpay's balance (what is still captured) rather than the original amount — so a payment
that was partially refunded directly in the Quickpay manager refunds only the remainder instead of failing. The
balance is also persisted into the payment details whenever a callback or the
reconciliation command reconciles the payment, also when its state doesn't
change. An explicit
refund_amount/capture_amountin the payment details is passed through to Quickpay untouched for programmatic partial operations; note that Sylius' payment state machine still treats the payment as a whole — therefundtransition can only be applied once.
On the checkout payment step, each Quickpay payment method shows the brands its payment window will offer — derived
from the gateway configuration's Payment methods field, so nothing is configured twice and no API is called.
creditcard, mobilepay renders the Visa and Mastercard marks (what creditcard stands for is configurable) and the
MobilePay mark; a token without a bundled logo (e.g. resurs) renders as a small text label. Quickpay's token
grammar is understood: exclusions (!diners) are skipped, forced 3-D Secure (3d-creditcard) is ignored, and
regional/debit variants (visa-dk, mastercard-debet-dk, mobilepay-subscriptions) collapse onto their brand.
Bundled logos cover cards (Visa, Visa Electron, Mastercard, Maestro, American Express, Diners Club, Discover, JCB, UnionPay, Dankort, Forbrugsforeningen) and MobilePay, Apple Pay, Google Pay, Klarna, Anyday, Vipps, Swish, PayPal, ViaBill, Trustly, iDEAL, Sofort and paysafecard. They come from Shopify's MIT-licensed payment_icons; the marks remain their owners' trademarks and are shown only to indicate acceptance.
To use your own images, add or override a token, or hide one, configure the plugin — the value is an asset path (or
URL) as asset() understands it, or null to hide the token:
setono_sylius_quickpay:
checkout:
payment_method_logos:
mobilepay: build/images/mobilepay.svg
resurs: https://cdn.example.com/resurs.png
apple-pay: ~
creditcard_brands: [dankort, visa, mastercard] # what the `creditcard` token shows; default [visa, mastercard]The markup lives in @SetonoSyliusQuickpayPlugin/shop/checkout/select_payment/_payment_method_logos.html.twig (a
sylius_ui block on sylius.shop.checkout.select_payment.choice_item_content) and can be overridden like any bundle
template.
Every Quickpay payment that is still awaiting payment shows a Payment link panel on the admin order view: a copy-able link, and a button that emails it to the customer. Use it to collect payment for a phone or invoice order, or after a customer abandoned the checkout — anyone opening the link is taken through the normal flow into the Quickpay payment window, and the payment resolves exactly as after checkout.
The link is Sylius' own "pay for this order" url (sylius_shop_order_pay), built for the order's channel hostname:
nothing happens at Quickpay until the customer clicks, so it can be shown, copied and sent as often as needed, and it
also works for a payment whose Quickpay payment was never created (the flow creates it on first use). It is offered for
the order's last payment in state new on a Quickpay method, as long as the order is not cancelled — the same payment
Sylius' own "Pay" button in the customer account pays.
The email (@SetonoSyliusQuickpayPlugin/email/payment_link.html.twig, code setono_sylius_quickpay_payment_link)
is sent in the order's locale through Sylius' mailer and can be overridden like any Sylius email template.
Each Quickpay payment on the admin order view shows its live operation history — every authorize/capture/refund/cancel with amount, Quickpay status code and message, and timestamp, plus the captured balance and a test-mode badge. The data is fetched from Quickpay after the page has rendered, so the order page is never delayed by a slow gateway; if Quickpay cannot be reached, the panel shows an inline notice with a retry link. Nothing is stored — the panel reflects what Quickpay reports right now.
Quickpay flags payments it suspects of fraud, and the plugin surfaces that in three places:
- A Fraud suspected badge on the admin operation history panel, next to the test-mode badge.
- An opt-in capture guard: with
fraud.block_capture: true(see Configure the plugin), the automatic capture on the payment'scompletetransition is skipped when Quickpay reports the payment as fraud suspected — the completion itself is not blocked; the payment is logged and left for manual review, so you capture or cancel it in the Quickpay manager after looking at it. The check asks Quickpay at capture time and fails open: an unreachable Quickpay never blocks the payment flow. It costs one extra API call per automatic capture, which is why it is off by default. - A report mode on the reconciliation command that asks Quickpay for every payment flagged in the period, regardless of its local state — report only, nothing is transitioned:
bin/console setono:sylius-quickpay:reconcile-payments --fraud-suspected --since="7 days"All callbacks for what your store does arrive on a per-payment url the gateway mints and registers itself —
the payment window's outcome on the payment link's callback url, and capture/refund/cancel confirmations via the
QuickPay-Callback-Url header the gateway sends on every operation it issues. Payum checks the url's token and
the plugin handles the callback; none of it needs configuration.
The account-wide callback url of your Quickpay account (Settings → Integration → Callback url) is only consulted for operations made outside the store — a refund clicked in the Quickpay manager, a capture issued by another system talking to the API. If you do that and want your store to know, point the account-wide url at the plugin's endpoint:
https://your-shop.example/payment/quickpay/notify
That endpoint resolves the payment from the callback body (order_id → your order prefix → the Sylius payment)
precisely because one static url cannot carry a Payum token. A Quickpay account has exactly one such url, so when
several environments share an account, only the one it points at receives those callbacks — the others can rely on
the reconciliation command. Quickpay retries undelivered callbacks 24 times with
backoff.
Deliveries can race: Quickpay retries a callback it considers undelivered, and a retry can arrive while the
original is still being handled. Both callback urls reconcile the payment under a per-payment lock, held until the
state change is applied and saved. A callback arriving while the lock is held is answered with a 2xx and runs
nothing, so nothing is applied twice and Quickpay stops retrying; one arriving after the lock was released sees the
saved state and changes nothing. Nothing is lost either: handling a callback re-fetches the payment from Quickpay,
so the callback holding the lock sees the state the duplicate carried. The lock is taken before the callback's
signature is checked, so a forged callback arriving while the payment's lock is held is answered with a 2xx as well;
like any duplicate it runs nothing and changes nothing. A callback whose handling fails (Quickpay unreachable, a
state-machine callback throwing) saves nothing, not even a state change that was halfway applied, and is answered
with an error, so Quickpay's retry finds the payment as it was. The
reconciliation command takes the same lock, so a callback and the command never
reconcile a payment at the same time. The lock is a regular Symfony lock:
the plugin registers a named framework.lock resource, setono_sylius_quickpay, defaulting to the flock store,
which locks per server. If your shop runs on several servers, redefine the resource with a shared store, or two
servers can each handle one of a pair of duplicate callbacks:
# config/packages/lock.yaml
framework:
lock:
setono_sylius_quickpay: '%env(LOCK_DSN)%' # e.g. LOCK_DSN=redis://localhostCallbacks are normally how your store learns about a payment state change. If one never arrives — the store was unreachable until Quickpay gave up retrying, or the change was made outside the store and the account-wide callback url points elsewhere (see Callbacks) — the payment stays stuck in a non-final state and the order never completes. The plugin ships a reconciliation command that closes this gap by reconciling each stuck payment with its Quickpay payment:
bin/console setono:sylius-quickpay:reconcile-payments # last 7 days, max 100 payments
bin/console setono:sylius-quickpay:reconcile-payments --since="12 hours" --limit=50
bin/console setono:sylius-quickpay:reconcile-payments --dry-run # report only, change nothingEach stuck payment is reconciled: the command fetches its status from Quickpay and Sylius applies the state change its own mapping gives, exactly the change the callback would have made (a pending Quickpay payment, for example, moves a new payment to processing). Each payment is saved as soon as it is reconciled, and every payment that changed is listed with its state before and after and the state Sylius maps its Quickpay payment's status to:
Payment 42: "new" -> "authorized" (Quickpay → "authorized")
Payment 43: skipped, it is locked by a callback being handled or another run
[OK] 3 checked, 1 transitioned, 1 unchanged, 1 skipped, 0 errored
The command takes the same per-payment lock as the callbacks: a payment whose callback is being handled at that moment is skipped and reported as such, and the next run picks it up. A payment counted as unchanged kept its state; the payment details refreshed from Quickpay, such as its balance, are still saved. A payment that fails (Quickpay unreachable, a state-machine callback throwing, say Sylius failing to sell the order's inventory) is reported and counted, and nothing of it is saved, not even a state change that was halfway applied, so the next run retries it; the run goes on with the next payment. When the database fails to save a payment, Doctrine closes its entity manager and the rest of that run is reported as errored too; nothing of those payments is saved either, and the next run reconciles them.
--dry-run only reads: it reports each payment's state, the transition that would be applied (or no transition)
and the state the Quickpay payment's status maps to, and changes nothing — no state, no state-machine callbacks, no
database writes. It is safe against a production shop:
Payment 42: "new", would apply "authorize" (Quickpay → "authorized")
Scheduling is your application's choice; a cron entry along these lines is plenty:
*/30 * * * * /usr/bin/php /path/to/shop/bin/console setono:sylius-quickpay:reconcile-payments >> /var/log/quickpay-reconcile.log 2>&1The command exits non-zero when any payment could not be checked, so cron mail or your monitoring will surface persistent problems.
composer phpunit # unit and functional tests (the functional suite needs the test application's database)
composer analyse # static analysis (PHPStan)
composer check-style # coding standardsFor manual testing, use the credit card numbers from the Quickpay test data.
The plugin ships a doctor that runs the checks otherwise surfacing as support cases — try it first when something misbehaves, and after every configuration change:
bin/console setono:sylius-quickpay:doctor # read-only
bin/console setono:sylius-quickpay:doctor --live # also probes the payment link permissionFor every configured Quickpay gateway it verifies the api key against Quickpay, self-tests the private
key's checksum computation, checks that a configured agreement id exists on the account, and validates
the order prefix length — plus, across gateways, that no two share a prefix, and that the notify route
is registered at all. With --live it also creates a money-less test payment and attempts the payment
link PUT the checkout depends on, catching a missing Create or update payment link permission before
a customer does (the test payment remains visible on the account; it never carries money). The command
exits non-zero when any check fails, so it can run in CI or cron.
See UPGRADE-2.0.md for the full list of changes an upgrading store has to make — gateway configuration keys, removed imports, renamed routes and classes, the Klarna removal, and the behavioral changes around refunds and callbacks. Coming from an earlier 2.0 alpha, read its Upgrading from an earlier 2.0 alpha section.
Run the doctor first — it detects every case below.
-
Not authorized: Not authorized to PUT /payments/:id/linkat a/payment/authorize/...url:In the Quickpay manager, check under Settings → Users that the API user's User permissions have the
PUTcheckbox checked for Create or update payment link. Also make sure the Api key field of your payment method holds the API user's key (Settings → Users) — not the api key of a Payment Window agreement (Settings → Integration). -
Validation error: order_id already exists on another paymentQuickpay requires the
order_idto be unique across everything sharing the account, and theorder_idis your order number with the payment method's Order prefix prepended. Change the prefix to a string unique per project and environment, e.g.qp_<project>_<date>_, whenever:- you recreate a development database, so order numbers start over and collide with the ones already sent
to Quickpay — bump the
<date>part; - several projects or environments share the same Quickpay (developer) account credentials.
- you recreate a development database, so order numbers start over and collide with the ones already sent
to Quickpay — bump the
-
Validation error: order_id must have length between 4 and 20Shorten the Order prefix of your Quickpay payment method to 11 characters or less.
-
Payment 42: skipped, it is locked by a callback being handled or another runThe reconciliation command found the payment's lock held: a callback for it was being handled at that moment, or another run of the command was reconciling it. Nothing was changed, and the next run picks the payment up. A lock is never left behind for long: a
flocklock is released with the process holding it, and a lock in a shared store expires after 60 seconds.