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(), 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'.
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 |
|---|---|
| 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 |
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
}
}
}
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 PaymentInterface. PaymentRouter validates this at discovery time and silently skips any class that does not. If two installed packages declare the same module identifier, a RuntimeException is thrown at boot.