Payments
Canvas includes a modular payment system built around PaymentRouter, which discovers installed payment provider packages automatically and routes payment operations to the correct provider based on the paymentModule field.
Core Concepts
- PaymentRouter — Discovers installed provider packages via composer metadata and routes
initiate(),refund(),chargeRecurring(), andgetPaymentOptions()calls to the correct provider based on thepaymentModulefield. - PaymentInterface — The contract every provider package implements.
- Driver — A concrete provider implementation (e.g.
Quellabs\Payments\Mollie\Driver). Registered automatically when its package is installed. - paymentModule — A string identifier that selects both the provider and the payment method, e.g.
'mollie_ideal'or'mollie_creditcard'. - Mandate — A customer's authorization for off-session recurring charges, created the first time they complete a payment with
sequenceType: SequenceType::First. See Recurring Payments.
Installation
Install the router and at least one provider package:
composer require quellabs/canvas-payments
composer require quellabs/canvas-payments-mollie
PaymentRouter scans installed packages for a provider entry in their composer metadata and registers them automatically.
Supported Providers
The following provider packages are available:
| Provider | Package | Recurring |
|---|---|---|
| Adyen | quellabs/canvas-payments-adyen | — |
| Buckaroo | quellabs/canvas-payments-buckaroo | — |
| Klarna | quellabs/canvas-payments-klarna | — |
| Mollie | quellabs/canvas-payments-mollie | ✓ |
| MultiSafepay | quellabs/canvas-payments-multisafepay | — |
| Pay.nl | quellabs/canvas-payments-paynl | — |
| PayPal (REST) | quellabs/canvas-payments-paypal | — |
| PayPal Express (NVP) | quellabs/canvas-payments-paypal-express | — |
| Rabobank Smart Pay | quellabs/canvas-payments-rabosmartpay | — |
| Stripe | quellabs/canvas-payments-stripe | — |
| Xpay | quellabs/canvas-payments-xpay | — |
See Recurring Payments for details. Providers without support throw PaymentInitiationException / PaymentMandateException when chargeRecurring(), getMandates(), or revokeMandate() are called.
Publishing the config file
Each driver requires its own config file for API credentials and settings. Run the corresponding command to publish it to /config:
php ./vendor/bin/sculpt adyen:init
php ./vendor/bin/sculpt buckaroo:init
php ./vendor/bin/sculpt klarna:init
php ./vendor/bin/sculpt mollie:init
php ./vendor/bin/sculpt multisafepay:init
php ./vendor/bin/sculpt paynl:init
php ./vendor/bin/sculpt paypal:init
php ./vendor/bin/sculpt paypal-express:init
php ./vendor/bin/sculpt rabosmartpay:init
php ./vendor/bin/sculpt stripe:init
php ./vendor/bin/sculpt xpay:init
Initiating a Payment
Inject PaymentRouter via Canvas DI and call initiate() with a PaymentRequest:
use Quellabs\Payments\Contracts\PaymentInterface;
use Quellabs\Payments\Contracts\PaymentRequest;
use Quellabs\Payments\Contracts\PaymentInitiationException;
class CheckoutService {
public function __construct(private PaymentInterface $router) {}
public function startPayment(): string {
$request = new PaymentRequest(
paymentModule: 'mollie_ideal',
amount: 999, // in minor units — €9.99
currency: 'EUR',
description: 'Order #12345',
issuerId: 'ideal_INGBNL2A',
);
try {
$result = $this->router->initiate($request);
// Redirect the customer to the payment page
return $result->redirectUrl;
} catch (PaymentInitiationException $e) {
// handle error
}
}
}
All amounts are in minor units. 999 represents €9.99, 2500 represents €25.00.
Handling Webhook Events
When a payment status changes, the provider's webhook controller emits a payment_exchange signal carrying a PaymentState object. Listen for it using the @ListenTo annotation on any Canvas-managed class:
use Quellabs\Canvas\Annotations\ListenTo;
use Quellabs\Payments\Contracts\PaymentState;
use Quellabs\Payments\Contracts\PaymentStatus;
class OrderService {
/**
* @ListenTo("payment_exchange")
*/
public function onPaymentExchange(PaymentState $state): void {
match ($state->state) {
PaymentStatus::Paid => $this->markPaid($state->transactionId, $state->valuePaid),
PaymentStatus::Canceled => $this->markCanceled($state->transactionId),
PaymentStatus::Expired => $this->markExpired($state->transactionId),
PaymentStatus::Refunded => $this->handleRefund($state),
default => null,
};
}
}
Canvas wires the listener automatically. The payment_exchange signal carries only state — database handling belongs to your application.
On PaymentStatus::Paid events, some providers include a paymentReference in the metadata array. When present, this value must be used as the paymentReference in any subsequent RefundRequest instead of the transactionId. Your application is responsible for persisting it alongside the transaction:
PaymentStatus::Paid => $this->markPaid(
$state->transactionId,
$state->valuePaid,
$state->metadata['paymentReference'] ?? null, // store this if present — required for refunds
),
Issuing a Refund
Call refund() with a RefundRequest. Omitting amount (or passing null) will refund the full payment amount:
use Quellabs\Payments\Contracts\RefundRequest;
use Quellabs\Payments\Contracts\PaymentRefundException;
try {
$result = $this->router->refund(new RefundRequest(
paymentReference: 'tr_7UhSN1zuXS',
paymentModule: 'mollie_ideal',
amount: 500, // in minor units — €5.00
currency: 'EUR',
description: 'Partial refund for order #12345',
));
echo $result->refundId;
} catch (PaymentRefundException $e) {
// handle error
}
Fetching Refunds
Retrieve all refunds issued for a transaction by calling getRefunds() on the provider directly. Note that not all providers expose refund retrieval — check your provider's documentation before relying on this method.
use Quellabs\Payments\Contracts\PaymentInterface;
use Quellabs\Payments\Contracts\PaymentRefundException;
class RefundReportService {
public function __construct(private PaymentInterface $router) {}
public function getRefunds(string $transactionId): array {
try {
return $this->router->getRefunds($transactionId); // array of RefundResult
} catch (PaymentRefundException $e) {
// handle error
}
}
}
Recurring Payments
Some providers support billing a customer again later without any further customer interaction, using a saved mandate (SEPA direct debit, a tokenized card, etc.). Currently only Mollie implements this; on every other provider the recurring methods throw PaymentInitiationException / PaymentMandateException with a "not supported" message. Check the provider's README before relying on it.
The flow has three steps:
- A first payment — a normal
initiate()redirect, but flagged so the provider creates a customer and, once completed, attaches a mandate to it. - Any number of recurring charges against that mandate, initiated server-side with no redirect — e.g. from a scheduled job.
- Optionally, revoking the mandate once the customer should no longer be billed.
1. First payment — create a customer and mandate
Pass sequenceType: SequenceType::First on the PaymentRequest. If you don't already have a provider customer id, leave customerReference unset — the provider creates one automatically (Mollie sources the name/email from billingAddress). Persist InitiateResult::$customerReference from the response; you'll need it for every recurring charge and mandate lookup afterwards.
use Quellabs\Payments\Contracts\PaymentInterface;
use Quellabs\Payments\Contracts\PaymentRequest;
use Quellabs\Payments\Contracts\SequenceType;
use Quellabs\Payments\Contracts\PaymentInitiationException;
class SubscriptionService {
public function __construct(private PaymentInterface $router) {}
public function startSubscription(): string {
$request = new PaymentRequest(
paymentModule: 'mollie_ideal',
amount: 999, // in minor units — €9.99
currency: 'EUR',
description: 'Subscription setup',
billingAddress: $address, // used to create the provider customer
sequenceType: SequenceType::First,
);
try {
$result = $this->router->initiate($request);
// Persist $result->customerReference on the user/subscription record
$this->persistCustomerReference($result->customerReference);
return $result->redirectUrl;
} catch (PaymentInitiationException $e) {
// handle error
}
}
}
The customer completes this payment like any other initiate() flow — status still arrives via the regular payment_exchange signal.
2. Charging the mandate
Call chargeRecurring() with a RecurringChargeRequest, resolved by paymentModule just like initiate(). There is no browser involved — InitiateResult::$redirectUrl is always null on the result, and the resulting payment's status is delivered through the same payment_exchange signal as any other payment.
use Quellabs\Payments\Contracts\RecurringChargeRequest;
use Quellabs\Payments\Contracts\PaymentInitiationException;
$request = new RecurringChargeRequest(
paymentModule: 'mollie_ideal',
customerReference: $customerReference,
amount: 999, // in minor units — €9.99
currency: 'EUR',
description: 'Subscription renewal',
);
try {
$result = $this->router->chargeRecurring($request);
// $result->redirectUrl is always null — wait for the payment_exchange signal
} catch (PaymentInitiationException $e) {
// handle error
}
3. Inspecting and revoking mandates
getMandates() and revokeMandate() are resolved by driver name, like exchange() and getRefunds():
use Quellabs\Payments\Contracts\PaymentMandateException;
try {
$mandates = $this->router->getMandates('mollie', $customerReference); // array of MandateInfo
foreach ($mandates as $mandate) {
echo $mandate->mandateId . ' — ' . $mandate->status->value;
}
// Prevent any further recurring charges against a mandate
$this->router->revokeMandate('mollie', $customerReference, $mandate->mandateId);
} catch (PaymentMandateException $e) {
// handle error
}
Scheduling when to charge, and persisting customerReference/mandate ids per user, is your application's responsibility — the payment package only wraps the provider's API calls.
Payment Options
Some payment methods expose issuer or bank selection (iDEAL, KBC, gift cards). Fetch available options to present to the customer before initiating payment:
use Quellabs\Payments\Contracts\PaymentException;
try {
$issuers = $this->router->getPaymentOptions('mollie_ideal');
foreach ($issuers as $issuer) {
echo $issuer['name'] . ' — ' . $issuer['id'];
}
} catch (PaymentException $e) {
// handle error
}
Methods without issuer selection return an empty array.
Payment State
PaymentState is emitted via the payment_exchange signal on every webhook hit.
| Property | Type | Description |
|---|---|---|
provider | string | Provider identifier, e.g. 'mollie' |
transactionId | string | Provider-assigned transaction ID |
state | PaymentStatus | Current payment state |
internalState | string | Raw status string from the provider |
valuePaid | int | Total amount paid in minor units |
valueRefunded | int | Total amount refunded so far in minor units |
currency | string | ISO 4217 currency code |
metadata | array | Metadata passed through from the original request |
Payment Statuses
| Status | Description |
|---|---|
PaymentStatus::Pending | Payment is open or pending |
PaymentStatus::Paid | Payment completed successfully |
PaymentStatus::Canceled | Customer canceled — definitive |
PaymentStatus::Expired | Customer abandoned, or bank transfer timed out |
PaymentStatus::Failed | Payment failed and cannot be retried |
PaymentStatus::Refunded | Payment was refunded |
PaymentStatus::Redirect | Payment requires a redirect to an external page |
PaymentStatus::Unknown | Unrecognised status from the provider |
Discovering Registered Modules
Retrieve all payment module identifiers currently registered across all installed providers:
$modules = $this->router->getRegisteredModules();
// ['mollie', 'mollie_ideal', 'mollie_creditcard', ...]
Adding a Provider Package
Any package can register itself as a payment provider by declaring a provider entry in its composer.json:
"extra": {
"discover": {
"payments": {
"provider": "Quellabs\\Payments\\Mollie\\Driver",
"config": "/config/mollie.php"
}
}
}
The declared class must implement PaymentProviderInterface, which includes chargeRecurring(), getMandates(), and revokeMandate() alongside initiate(), refund(), and getPaymentOptions(). PaymentRouter validates this at discovery time and silently skips any class that does not implement it. If your provider doesn't support recurring payments, implement these three methods by throwing PaymentInitiationException / PaymentMandateException rather than omitting them — see the existing provider packages for the expected message format. If two installed packages declare the same module identifier, a RuntimeException is thrown at boot.