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.

explanation

Core Concepts

  • PaymentRouter — Discovers installed provider packages via composer metadata and routes initiate(), refund(), and getPaymentOptions() calls to the correct provider based on the paymentModule field.
  • 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
Adyenquellabs/canvas-payments-adyen
Buckarooquellabs/canvas-payments-buckaroo
Klarnaquellabs/canvas-payments-klarna
Molliequellabs/canvas-payments-mollie
MultiSafepayquellabs/canvas-payments-multisafepay
Pay.nlquellabs/canvas-payments-paynl
PayPal (REST)quellabs/canvas-payments-paypal
PayPal Express (NVP)quellabs/canvas-payments-paypal-express
Rabobank Smart Payquellabs/canvas-payments-rabosmartpay
Stripequellabs/canvas-payments-stripe
Xpayquellabs/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
providerstringProvider identifier, e.g. 'mollie'
transactionIdstringProvider-assigned transaction ID
statePaymentStatusCurrent payment state
internalStatestringRaw status string from the provider
valuePaidintTotal amount paid in minor units
valueRefundedintTotal amount refunded so far in minor units
currencystringISO 4217 currency code
metadataarrayMetadata passed through from the original request

Payment Statuses

Status Description
PaymentStatus::PendingPayment is open or pending
PaymentStatus::PaidPayment completed successfully
PaymentStatus::CanceledCustomer canceled — definitive
PaymentStatus::ExpiredCustomer abandoned, or bank transfer timed out
PaymentStatus::FailedPayment failed and cannot be retried
PaymentStatus::RefundedPayment was refunded
PaymentStatus::RedirectPayment requires a redirect to an external page
PaymentStatus::UnknownUnrecognised 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.