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(), chargeRecurring(), 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'.
  • 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
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

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:

  1. A first payment — a normal initiate() redirect, but flagged so the provider creates a customer and, once completed, attaches a mandate to it.
  2. Any number of recurring charges against that mandate, initiated server-side with no redirect — e.g. from a scheduled job.
  3. 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
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 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.