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()andcontextForTenant()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
testmodeinto 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:
- It reads the payment from Mollie by the id in the request (through the right account in Connect mode) and requires the package's own metadata on it.
- It checks the status before any state change, so a redelivered webhook cannot re-apply a transition.
- It processes the update in the request and answers 200. An unknown payment record answers 404; a failure answers 500, and Mollie retries.
- A payment of a tenant whose Mollie account was disconnected is flagged and answered 200, because nothing can read it any more — see Disconnecting a tenant.
- An IP throttle from
larsasub.webhooks.rate_limit(default'300,1';nulldisables) is the only brake on junk traffic.
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).
- Create a service class extending
PaymentProviders\Contracts\PaymentProviderServiceand implement every abstract method.prepareRefund()andcreateRefund()are not abstract: without themRefundService::refund()refuses your payments withRefundBlocker::ProviderUnsupported. Override both to support refunds. The ceiling reads whatprepareRefund()leaves in the payment'sprovider_refund_detailsandprovider_chargeback_details— entries withrefund_id/chargeback_id,status,amount(decimal string),currencyand, for chargebacks,reversed_at— so sync those there first, the wayrefreshProviderRefundDetails()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-chargesflags a charge whoseexecuteCharge()throws. Throw aLarsaSubExceptionwhen nothing went out: the customer may then pay the charge by hand. Any other exception, and aLarsaSubExceptionthat wraps one, leaves itPaymentInProgressuntil the claim is resolved, because the provider may be collecting it. The command never handsexecuteCharge()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 $beforeFirstChargeofcreateSubscription()andcreateSubscriptionWithMandate()throughBeforeFirstChargeHook::resolve($beforeFirstCharge)intoBillableSubscriptionService::createSubscription(beforeFirstCharge: …). Skipping it drops thesubscription.before_first_chargeconfig fallback. - In
createSubscriptionWithMandate(), callBillableSubscriptionService::assertReusableMandate($billable, $mandate, $paymentMethod)before you write anything, and associate the mandate thatlockMandateForReuse()returns inside the creating transaction, afterBillable::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.
- Create provider-specific customer and mandate models extending
Models\ProviderCustomerModelandModels\ProviderMandateModel. - Add a controller per payment method, implementing
Contracts\PaymentMethodControllerInterface— extending the abstractPaymentProviders\Contracts\PaymentMethodControllergives you the webhook throttle. List the controllers in your service's$providerControllers(provider_id→ controller class); the inheritedroutes()registers each controller's routes. - 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.