LarsaSub is launching soon. Purchases and account registration open shortly.

Documentation for larsasub v0.38.2. On an older version? Your release notes ship with the package (CHANGELOG.md).

The Mollie provider

Mollie is the shipped payment provider. This page covers the provider contract you call, how the package picks the right Mollie client per account, test mode, the Mollie models, webhooks, and how to plug in a provider of your own.

Everything the rest of the package needs from a provider sits behind one contract, so a second provider is a matter of implementing that contract and registering it.

The provider contract

Ask the factory for the configured provider and call the contract on it:

use PetersDevelopment\LarsaSub\PaymentProviders\PaymentProviderFactory;

$provider = PaymentProviderFactory::make(config('larsasub.provider'));   // 'mollie' by default

make() returns the contract type, PaymentProviders\Contracts\PaymentProviderService. Type against that, never against MollieService: the Mollie implementation behind it is internal. The factory resolves the class from larsasub.providers through the container and throws LarsaSubException for an unknown or misconfigured provider.

The methods a host is most likely to call:

// Payment creation — the billable parameter is typed Model, not BillableModel
$provider->createSubscription(Model $billable, Subscription $subscription, PaymentMethod $paymentMethod, ?Closure $beforeFirstCharge = null): RedirectResponse;
$provider->createSubscriptionWithMandate(Model $billable, Subscription $subscription, PaymentMethod $paymentMethod, ProviderMandateModel $mandate, ?Closure $beforeFirstCharge = null): RedirectResponse;
$provider->createSinglePayment(SinglePayment $singlePayment, PaymentMethod $paymentMethod): RedirectResponse; // paid with $paymentMethod, written onto the payment first
$provider->startManualChargePayment(RecurringCharge $charge, ?PaymentMethod $paymentMethod = null): RedirectResponse; // a single method; null = the first one available
$provider->executeCharge(RecurringCharge $charge): void; // planned charges only: refuses an attempted or flagged one

// Mandates
$provider->createMandateSetupPayment(Model $billable, PaymentMethod $paymentMethod): RedirectResponse;
$provider->createMandate(RecurringCharge $charge): ?ProviderMandateModel;
$provider->revokeMandate(ProviderMandateModel $mandate): void;
$provider->refreshMandateStatus(BaseModel $mandate): void;
$provider->getMandatesForBillable(Model $billable, ?Model $tenant = null): Collection;
$provider->getMandateById(int $mandateId): ?ProviderMandateModel;
$provider->getUsableMandatesQuery(): Builder;
$provider->getRemovedMandatesQuery(): Builder;          // soft-deleted mandates
$provider->billableHasUsableMandate(?Billable $billable): bool;   // tenant-scoped "is there something to collect with?"
$provider->recurringMethodForMandate(RecurringCharge $charge): PaymentMethod;

// Method availability
$provider->getActiveMethodIds(?Model $tenant = null): array;   // provider_id strings active at the provider

// New payments for a billable (Connect: refused while the tenant's disconnect drains)
$provider->refusesNewPaymentsFor(Model $billable): bool;      // true = nothing new may start
$provider->assertAcceptsNewPaymentsFor(Model $billable): void;  // throws ConnectDisconnectPendingException

// Webhook processing
$provider->processFirstPayment(RecurringCharge $charge, mixed $providerPayment): void;
$provider->processRecurringCharge(RecurringCharge $charge, mixed $providerPayment): void;
$provider->processSinglePayment(SinglePayment $singlePayment, mixed $providerPayment): void;

// Provider detail refresh
$provider->refreshProviderPaymentDetails(RecurringCharge|SinglePayment $payment, mixed $providerPayment): void;
$provider->refreshProviderRefundDetails(RecurringCharge|SinglePayment $payment, mixed $providerPayment): void;
$provider->refreshProviderChargebackDetails(RecurringCharge|SinglePayment $payment, mixed $providerPayment): void;
$provider->isPaymentActiveAtProvider(?string $providerPaymentStatus): bool;

// Refund initiation — NOT abstract; the default refuses
$provider->prepareRefund(RecurringCharge|SinglePayment $payment, string $keyHash): RefundProviderState;
$provider->createRefund(RecurringCharge|SinglePayment $payment, int $amountCents, string $description, string $keyHash): RefundRequest;

Webhook validation is static, and is what each webhook controller calls with the incoming request:

PaymentProviderService::validatePayment(Request $request, array $required_meta = []): mixed;

The Mollie implementation fetches the payment from Mollie by the id in the request and returns it; it never trusts payment data from the request body.

The calls that create or collect a payment end in an HTTP call to Mollie. Call them outside your own database transactions — see Wrapping a LarsaSub call in your own transaction.

Resolving a client

Every Mollie API call goes through MollieClientResolver, which routes to the right account. Never call Mollie::api() directly: in Connect mode it silently talks to the platform's own account instead of the tenant's.

use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieClientResolver;

$client  = MollieClientResolver::forBillable($billable);   // auto-detects the tenant
$client  = MollieClientResolver::forTenant($tenant);
$client  = MollieClientResolver::platform();               // the global platform client

$context = MollieClientResolver::contextForBillable($billable);
$context = MollieClientResolver::contextForTenant($tenant);

$tenant  = MollieClientResolver::tenantForBillable($billable);   // whose account, or null for the platform's key
$context = MollieClientResolver::platformOrganizationContext();  // the organization token, for the Settlements API

How a billable is routed:

Situation Client
Simple mode The global Mollie::api() client
A platform billable, or a billable.platform_model instance The global client — the platform's own key
A tenant billable in Connect mode A new client carrying the tenant's OAuth access token
A tenant billable whose tenant cannot be resolved Throws PaymentProviderServiceException
A tenant whose Mollie account was disconnected Throws ConnectAccountDisconnectedException (a subclass of the above)
A tenant with no connected Mollie account at all Throws PaymentProviderServiceException
A failing OAuth token refresh Throws — refreshIfNeeded() rethrows

In Connect mode every call creates a new MollieApiClient rather than reusing the singleton, so concurrent payments across tenants cannot bleed credentials into each other.

Connect resolution fails closed — there is no fallback to the global client. Routing a tenant's payment to the platform's own Mollie credentials would move the wrong party's money. forBillable(), forTenant(), contextForBillable() and contextForTenant() all throw in the cases above (the first two wrap the other two), so budget for the exception rather than for a silent degradation.

contextForBillable() returns a MollieClientContext with everything a Connect payment needs:

$context->client;                    // the MollieApiClient
$context->profileId;                 // the tenant's website profile (Connect), or null
$context->organizationId;
$context->draining;                  // the tenant's disconnect is draining: payment creation refuses
$context->isTestMode();
$context->isConnectedAccount();
$context->isPlatformOwnedAccount();  // the connected account IS connect.platform_organization_id
$context->shouldApplyFees();         // connected, and not the platform's own organization
$context->testmodeQuery();           // ['testmode' => true] on a connected account in test mode, else []

Test mode

API keys carry their mode in the key prefix (test_), but OAuth access tokens do not — every call made with a tenant's token needs an explicit testmode flag. LarsaSub derives test mode from the platform key: MOLLIE_KEY (config('mollie.key')) starting with test_.

How the flag is passed depends on the endpoint, and getting it wrong is silent:

Never merge testmode into a Mollie create payload. mollie-api-php's create-request factories drop unknown payload keys without complaint, so the call then runs in live mode.

use PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieClientContext;

// Payment creation: pass it as the $query argument, via the context helper
$context->client->payments->create($paymentData, $context->testmodeQuery());

// Customer creation: pass it as the separate $testmode argument
$client->customers->create([...], MollieClientContext::testmodeParams());

// GET / list / delete calls: pass it as the query or testmode parameter
$client->payments->get($id, MollieClientContext::testmodeParams());

MollieClientContext::testmodeParams() returns ['testmode' => true] only when Connect is enabled and the platform key is a test key, and [] otherwise — so it is safe to pass on every call. The package's own payments go through MollieClientContext::createPayment(), which adds the testmode query and a stable idempotency key per payment attempt.

Two endpoints accept neither. methods->all() whitelists its query keys and its GetAllMethodsRequest implements no SupportsTestmode, so even client-level test(true) is ignored. The profiles list (profiles->page(), profiles->iterator()) takes no query at all, and GetPaginatedProfilesRequest implements no SupportsTestmode either. Build the request yourself and add the query parameter to it — MollieMethodCatalog::fetchMethodDetails() (an internal class: read it, do not call it) and MollieConnectService::fetchProfiles() are the worked examples.

Mollie models

Three models hold what the package knows about Mollie. They are public: query them and relate to them, but let the package write them. The one exception is an import of customers who already have a mandate at Mollie: record their MollieCustomer yourself first — see Adopting existing mandates.

MollieCustomer

MollieCustomer (larsasub_mollie_customers) is the customer reference in Mollie, one per registration per account. In Connect mode customers are scoped by tenant_id/tenant_type (null = the platform's own account), and MollieCustomer::forTenant($tenant) narrows to one tenant's.

MollieMandate

MollieMandate (larsasub_mollie_mandates) is a mandate at Mollie: SEPA Direct Debit, credit card or PayPal. It carries no tenant columns of its own — it belongs to a tenant through its customer, and $mandate->belongsToTenant($tenant) answers that.

consumer_account says what the mandate collects from: the IBAN for SEPA Direct Debit, the card's last four digits for a card, the PayPal payer id for PayPal. It is recorded from the first payment. A status refresh (refreshMandateStatus(), also run by larsasub:validate-mandates) writes the method's own detail from Mollie and keeps the stored value when Mollie reports none — for PayPal always, since its mandate carries no payer id. Show it with $mandate->getMaskedConsumerAccount(): NL91**********4300, or **** 1234 for a card.

consumer_account is NOT NULL and encrypted, and a mandate requires a MollieCustomer (a restricted foreign key) — worth knowing when you build fixtures; see Testing.

MollieConnectedAccount

MollieConnectedAccount (larsasub_mollie_connected_accounts) is a tenant's OAuth connection, Connect mode only. One row per tenant.

Property Description
reference_code 4-character unique code for the tenant
tenant_id, tenant_type Polymorphic tenant
access_token, refresh_token Encrypted at rest
expires_at Token expiry
mollie_organization_id, mollie_profile_id The organization, and the website profile payments are created on
mollie_profile_name, mollie_profile_count Display snapshots for the profile picker — refreshed on connect and by listProfiles()
profile_selected_at null = the package picked the profile, not the tenant. See Choosing the website profile
organization_name
connected_at, disconnected_at
disconnect_requested_at, disconnect_deadline_at Set while a requested disconnect drains. See Disconnecting a tenant
settlements_synced_at, settlements_sync_error The last successful settlement sync, and the short code of the last failed one. See Settlements
$account->isConnected();
$account->isDraining();        // a disconnect was requested and has not finished
$account->refreshIfNeeded();   // refreshes under a DB lock

MollieConnectedAccount::forTenant($tenant)->first();
MollieConnectedAccount::connected()->get();
MollieConnectedAccount::draining()->get();

Never bypass refreshIfNeeded(): Mollie rotates refresh tokens, and the row lock it takes is what stops two concurrent workers from both spending the same one. A 4xx rejection of the refresh disconnects the account — see Losing a connection.

IsMollieBillableModel

The IsMollieBillableModel trait adds the customer and mandate helpers to your owner model:

$user->mollieCustomers();      // HasManyThrough — the customers of this owner's registration
$user->getMollieCustomer();    // get-or-create via the Mollie API, scoped by tenant; throws LarsaSubException
$user->mollieMandates();       // HasManyThrough — this owner's mandates
$user->mollieMandate($method); // the first mandate for a payment method, usable or not, or null
$user->createMollieMandate($method, $consumerAccount);              // create one at Mollie from an IBAN
$user->recordExistingMollieMandate($method, $mandateId, $account);  // record one Mollie already made

The two write methods are idempotent: recording a mandate the package already holds returns the existing row, and revives it when it had been marked unusable or removed.

Webhooks

Mollie calls the package when a payment changes status. The webhook URL is set on every payment the package creates, under /api:

https://your-domain.com/api/larsasub/webhooks/mollie/{method}/{type}

The exact set per method is in Routes and webhooks.

What a webhook does with a delivery:

For local development, point the webhook URLs at a tunnel. The value is used in the local and development environments only:

LARSASUB_NGROK_DOMAIN=https://your-subdomain.ngrok.io

Adding your own provider

A provider is a service class plus one controller per payment method. Expect to read the upgrade notes on every minor release: the provider contracts may still change, also after 1.0, until a second provider ships (see What is public).

  1. Create a service class extending PaymentProviders\Contracts\PaymentProviderService and implement every abstract method.
    • prepareRefund() and createRefund() are not abstract: without them RefundService::refund() refuses your payments with RefundBlocker::ProviderUnsupported. Override both to support refunds. The ceiling reads what prepareRefund() leaves in the payment's provider_refund_details and provider_chargeback_details — entries with refund_id / chargeback_id, status, amount (decimal string), currency and, for chargebacks, reversed_at — so sync those there first, the way refreshProviderRefundDetails() does.
    • Let executeCharge() claim the charge (charged_at) before the provider call and write the provider id and status after it, as the Mollie implementation does. larsasub:execute-charges flags a charge whose executeCharge() throws. Throw a LarsaSubException when nothing went out: the customer may then pay the charge by hand. Any other exception, and a LarsaSubException that wraps one, leaves it PaymentInProgress until the claim is resolved, because the provider may be collecting it. The command never hands executeCharge() a charge with nothing to collect: it settles that one itself.
    • refusesNewPaymentsFor() defaults to false; override it only if your provider has a state in which nothing new may start.
    • billableHasUsableMandate() refuses any tenant scope it cannot honour; override it if your mandates are tenant-aware.
    • Pass the ?Closure $beforeFirstCharge of createSubscription() and createSubscriptionWithMandate() through BeforeFirstChargeHook::resolve($beforeFirstCharge) into BillableSubscriptionService::createSubscription(beforeFirstCharge: …). Skipping it drops the subscription.before_first_charge config fallback.
    • In createSubscriptionWithMandate(), call BillableSubscriptionService::assertReusableMandate($billable, $mandate, $paymentMethod) before you write anything, and associate the mandate that lockMandateForReuse() returns inside the creating transaction, after Billable::lockSubtree(). Collect the first charge from that returned row too, not from the object you were handed. That is the guard: without it, a caller that passes another customer's mandate collects from that customer.
  2. Create provider-specific customer and mandate models extending Models\ProviderCustomerModel and Models\ProviderMandateModel.
  3. Add a controller per payment method, implementing Contracts\PaymentMethodControllerInterface — extending the abstract PaymentProviders\Contracts\PaymentMethodController gives you the webhook throttle. List the controllers in your service's $providerControllers (provider_id → controller class); the inherited routes() registers each controller's routes.
  4. Register the service in the config:
'provider' => 'acme',

'providers' => [
    'mollie' => \PetersDevelopment\LarsaSub\PaymentProviders\Mollie\MollieService::class,
    'acme'   => \App\Billing\AcmeService::class,
],

The route file asks the registry for the active provider's routes, so your webhooks are registered — not Mollie's. A provider that cannot be resolved at boot registers no webhooks and logs a warning, so check the log after a deploy.

Services resolve through the container, so you can also override one method of the shipped provider with a container binding on its class instead of a new entry.