pushery/billing-for-laravel
pushery/billing-for-laravel is a Laravel package for adding billing and subscription features to your application, with configurable plans, invoicing, and payment handling for a lightweight, straightforward integration.
Install the package, publish its config and migrations, then set the provider credentials for the install's own driver (this is separate from the per-shop payment accounts added in v0.45):
composer require <vendor>/<package>
php artisan vendor:publish --tag=billing-config
php artisan migrate
Set at minimum BILLING_DRIVER, the matching STRIPE_* / MOLLIE_* keys, and check these before you write any code, because they decide whether pages, webhooks and invoices work at all:
billing.collection_timing (BILLING_COLLECTION_TIMING) — new default in v0.46 is in_advance on drivers the package bills itself. A new subscription's first period is charged at the next billing:run after the mandate exists (or when the trial ends), and each later period at the moment the previous one ends. Set in_arrears to keep the old end-of-period behaviour. Subscriptions that already existed stay in arrears regardless.billing.webhook_path — the base path for the two fixed webhook addresses plus the per-account address billing.webhook_path/{provider}/{account}.billing.webhook_middleware — CSRF is excluded by design (signatures authenticate deliveries); add your own middleware here, not in routes/web.php.billing.redirect_hosts — required for any Stripe account on a custom domain. Hosts that hand off to a provider page must be listed here or Checkout and the customer portal open nowhere (see Gotchas).v0.46 adds several migrations you should expect to run: subscription collection_timing (2026_10_15_000163_…), paused_at (…000164), mandate consents (…000165) and direct debit notices (…000166), expiring usage grants plus grace columns (…000169), and the AI spend table (…000170). Run them before deploying code that touches the new columns/tables.
Then verify the wiring rather than guessing: php artisan billing:doctor now also names every Stripe event type an endpoint at one of the package's addresses does not receive, and fails while one is missing. Run it in staging and CI.
Start with the smallest useful flow: resolve the driver from BillingManager and take a one-off payment, then listen for the resulting event.
use BillingManager; // or inject the manager
$result = $manager->driver()->charge($order, new ChargeOptions(
returnUrl: route('checkout.return', $order),
metadata: ['order_id' => $order->id],
));
return redirect($result->redirectUrl());
First places to read: BillingManager (driver + account factories), ChargeOptions (everything a checkout can pass), and the events — AccountPaymentChanged, RefundFailed, ChargebackReversed, plus the subscription lifecycle events SubscriptionPaused / SubscriptionUnpaused. If you run multiple shops, jump straight to BillingManager::forAccount(). If you bill subscriptions, read the collection_timing note above before writing any invoice math.
Single-tenant charging. Build ChargeOptions from whatever your checkout already decided — return/cancel/webhook URLs, locale, methods to offer, metadata, PaymentLines, billing/shipping PaymentAddress, and the Mollie Components card token. Mollie accepts all of it and rejects lines that don't sum to the amount before anything is sent, so validate totals before calling. Stripe only consumes the return address and metadata; pass the rest anyway, it's ignored rather than fatal.
Multi-shop / per-merchant accounts. Model each shop's provider account as a PaymentAccount and bind the PaymentAccounts contract in a service provider so the package can look accounts up from a webhook address or a job payload:
$this->app->bind(PaymentAccounts::class, EloquentPaymentAccounts::class);
Then charge into a specific account with that account's own credential, test mode and Mollie profile:
$driver = $manager->forAccount($shop->paymentAccount);
$driver->charge($order, $options);
Keep the install's own driver, seams and subscriptions untouched — those stay where they are; accounts are additive.
Shop-facing pages. Before rendering a page that mounts Mollie card fields, Stripe.js, or redirects to checkout, emit the account-scoped CSP from BillingManager::accountCsp() into the response header. Scope it to that route only, not your global CSP, so other pages stay tight. Same idea for payment method pickers: accountPaymentMethods() returns ids, names, logos, min/max amounts and issuer lists read with the account's credential (at Mollie, narrowed by amount, billing country and locale). At Stripe you get far less — no amounts, countries, names or logos — so don't build UI that assumes those fields are populated.
Webhooks. Point each account at billing.webhook_path/{provider}/{account}. The account is resolved through your PaymentAccounts binding before any request content is trusted, unknown accounts get a 404, verification uses that account's webhook secret, and deliveries are recorded under the account so the same event id across two accounts stays two deliveries. Handle everything through a single listener on AccountPaymentChanged (pending, authorized, paid, refunded in part/full, refund failed, charged back, failed, canceled, expired, with refunded/charged-back amounts, idempotency key and metadata) — this is the only event an account's webhooks raise, deliberately, so install-level listeners never see account traffic. Use billing:webhooks:replay to re-run a stored delivery through the account's mapper when debugging.
Stripe endpoint subscriptions are now checkable. v0.46 names the event types each endpoint needs: StripeWebhookEventMapper::EVENT_TYPES for the platform endpoint and StripeMarketplaceWebhookEventMapper::EVENT_TYPES for connected accounts. Put them in your provisioning script (Terraform/CLI/stripe webhooks update) instead of clicking types by hand, and let billing:doctor fail the build when an endpoint drifts. A missing type is silent at runtime — e.g. without invoice.upcoming, metered usage lands on the invoice a cycle late.
Subscriptions collected in advance. On a package-billed driver with collection_timing = in_advance, the period is paid up front, which changes how mid-period changes must be handled. v0.46 squares the remainder immediately:
A downgrade scheduled for period end is applied before the next period is charged, so that period is billed at the new tier. Read ReadsSubscriptionPayments when you need to know which order paid the period in progress: it is what makes ConsumerWithdrawal and ProratedCancellation refund the unused part of an advance-paid period.
Pausing instead of cancelling. PausesSubscriptions::pause() stops collecting an active, not-ending subscription and grants no access; unpause() resumes it. On a package-billed driver the paused duration is added to the current period, so paid days aren't lost, and a cancellation while paused ends the subscription at once. Stripe maps to pause_collection with void. Use it for dunning holds, seasonal shops and "pause my gym membership" flows; hook SubscriptionPaused / SubscriptionUnpaused for your own access locks and customer notices.
SEPA mandates without a payment. With Mollie, a mandate no longer requires a first charge. On your own form: get DirectDebitMandates::text() for the payer's country under a new reference, show exactly that text, then call give() with the account holder's name and IBAN. The IBAN is validated (country, length, check digits) before anything reaches Mollie; a refusal throws DirectDebitMandateRefused carrying a reason you can place next to the offending field. Consent is stored once — text, reference, account holder, agreed notice period, when and from where it was confirmed — and an erasure leaves it unlinked. German wording ships for AT, DE, LI, LU; otherwise supply a SepaMandateWording through billing.direct_debit.mandate_wordings. Drivers opt in by implementing CollectsDirectDebits.
Metered usage, grants and the brake. Grant units with an expiry — UsageGrants::grant($owner, $meter, $units, $expiresAt, reference: 'plan-…') — and usage draws from the tier's included allowance, then the soonest-expiring grant, then bought prepaid units, so nothing expiring goes to waste while non-expiring units sit idle. reference makes a grant idempotent per owner and meter. Schedule billing:usage:flush every minute: it writes off expired remainders and records the loss as usage.grant_expired. Grant-covered usage is netted out before the provider is told, exactly like prepaid. A hard-stop or refuse meter may declare grace units; requests under way can still pass the ceiling, and what they take becomes debt on the owner's balance row, repaid from the next grant before any of its units can be spent, with the owner never owing more than the grace at once.
AI cost tracking. Price a laravel/ai call with CostCalculator::costOf($model, TokenUsage::fromLaravelAi($response->usage)) — it reads the five buckets (uncached input, cache reads, cache writes, output, reasoning) from billing.ai.rates. The table is accessed by array key, so dotted model ids are safe, and each model can hold dated entries so a call is priced at its own day's rate. Costs are held in millionths and rounded once for toMoney(). Then count it: SpendBrake::spend($provider, $cost, owner: $user) checks and increments a daily bucket per provider (and per owner) in one statement, so two concurrent calls at the cap can't both pass. Refuse past billing.ai.daily_caps / billing.ai.owner_daily_cap and surface the refusal to the caller as "limit reached", not as a payment error.
Refunds. Stop relying on exceptions for refused refunds. Branch on the result:
$result = $billingAdmin->refund($charge, $amount);
if ($result->failed()) {
// $result->failureCode(): Stripe error code / failure_reason, or Mollie HTTP status / refund status
// $result->failureMessage(): the provider's own words
}
Genuine infrastructure errors (rejected credential, rate limit, outage) still throw — catch those as before.
Money-back reconciliation. Schedule billing:money-back:reconcile daily. It covers what webhooks structurally cannot: refunds that failed or were canceled before payout, and chargebacks the bank reversed. It lists refunds of the last 30 days and chargebacks of the last 180, records each outcome once in the delivery ledger, writes failed refunds to the log, and raises RefundFailed / ChargebackReversed through the effect bus. Both events are informational — deciding whether to re-refund, notify the customer, or claw back inventory is your listener's job. Drivers implement ReportsMoneyBackOutcomes where their webhooks don't report these outcomes; bind it alongside your driver.
Mollie Connect onboarding. For merchants without a Mollie account: build the authorization URL with MollieConnect (app, scopes, state), exchange the code for MollieTokens, and persist them against the PaymentAccount. Refresh tokens on a schedule (the refresh token is preserved when Mollie doesn't issue a new one), revoke on offboarding, and list website profiles with the connected account's own token. For merchants with no account at all, create a client link through your partner account. Store MollieOAuthApp/MollieTokens carefully — they mask secrets from debuggers and refuse serialization, so persist fields explicitly rather than dumping the object into a JSON column.
Account client tuning. Per-account Mollie HTTP behaviour is now environment-configurable: BILLING_MOLLIE_ACCOUNT_REQUEST_TIMEOUT, BILLING_MOLLIE_ACCOUNT_REQUEST_CONNECT_TIMEOUT, BILLING_MOLLIE_ACCOUNT_REQUEST_RETRIES for web requests, and the three BILLING_MOLLIE_ACCOUNT_BACKGROUND_* equivalents for queue workers, the scheduler and Artisan. Background workers usually want a longer timeout and more retries than a page render; set them per environment rather than in code.
Queues. A queued job must carry the provider and the API key (not a serialized PaymentAccount) and re-resolve the account through PaymentAccounts inside handle(). Same for anything that outlives the request.
Declared cancellations. If your application hosts the § 312k BGB cancellation button yourself, CancellationButton::declare() takes a cancellation made on your own pages (signed in or not) as a DeclaredCancellation, preserving the declaration as it was given.
billing:run after the mandate exists, or at trial end. Existing subscriptions keep arrears, so a mixed fleet is expected; read collection_timing per subscription in any reporting you write, don't assume the config value is the row's value. The new column is not nullable-by-accident: backfill and run the migration before deploying code that charges.active, pause silently keeps entitlement on. Filter on paused state too. On Stripe the pause is pause_collection with void — nothing is invoiced, so don't expect a proration.give() compares against the text text() returned for that reference and country. Cache the wording you rendered, not a re-fetched one — a config change of billing.direct_debit.mandate_wordings between render and submit produces a refusal. Map DirectDebitMandateRefused::$reason to a field; it is written to be shown to a person. Check IBAN validity client-side first so the round trip is rare, and remember the consent row is unlinked on erasure, so don't key your own audit records solely on the consent id.cancel() throws. CardPresentPayments::cancel() throws SaleNotCancelable (with the reader and payment) for a sale it cannot take off the terminal — Mollie never reports an open terminal payment as cancelable and only takes it off at the device. Catch it and tell the operator to press cancel on the device; otherwise the terminal refuses the next sale as busy until the payment expires. With no sale on the reader, cancel() still no-ops. Stripe is unchanged. Audit any code that assumed cancel() meant "the sale is gone".charge(). The signature is charge(..., ?ChargeOptions $options = null). Callers change nothing; implementations outside the package must add the parameter. A charge without options sends exactly what it always sent.$result->failed(). Only non-refund-specific errors (auth, rate limit, outage) still throw — keep that catch.billing.redirect_hosts. The package only hands out a provider page on a host its driver names, and a custom domain is a subdomain no driver can know. Unlisted hosts mean Checkout and the customer portal silently open nowhere, one log line per refusal naming the host. Search the log for the host before assuming the integration is broken — and add the host in every environment, including staging.StripeWebhookEventMapper::EVENT_TYPES / StripeMarketplaceWebhookEventMapper::EVENT_TYPES and gate deploys on billing:doctor. The failure mode is silence, not an error: metered usage billed a cycle late, no invoice.upcoming, etc.PaymentAccounts before the request body is trusted. Ship the binding and the account rows before the provider starts delivering, or you'll get a run of 404s and lost events. Deliveries recorded under a bad account binding won't be visible to the right account's mapper on replay.AccountPaymentChanged. If a listener isn't firing for a multi-shop charge, it's almost certainly subscribed to the wrong surface.BILLING_METERING_BACKOFF_SECONDS, BILLING_METERING_STALL_HOURS and BILLING_METERING_CLOSED_PERIOD_GRACE_MINUTES keep the shipped value when the string isn't a number; the grace may be 0, the backoff and stall need at least 1. Likewise the Mollie account timeouts/retries ignore non-whole numbers and timeouts below one second. A typo therefore leaves you on defaults with no warning — assert your effective settings in a boot test or billing:doctor-style check.billing:usage:flush is not optional — skip it and expired grants keep over-crediting owners while usage.grant_expired records never appear in your logs.toMoney(). Rates are dated per model, so a model rename or a mid-month price change needs a new dated entry, not an edit of the old one.extendAccountCsp(), extendAccountPaymentMethods(), CollectsDirectDebits for mandate support, and binding ReportsMoneyBackOutcomes where the webhook is silent. UnsupportedDriver messages name the exact registration a provider is missing — read them literally.PaymentAccount, MollieOAuthApp and MollieTokens mask credentials for debuggers and refuse serialization. That protects you from dd() and queue payloads, but it also means "just serialize it" isn't an option — carry identifiers and re-resolve.redirect_hosts? (2) did the webhook land under the right account id (check the delivery ledger, then billing:webhooks:replay)? (3) is the account's test mode consistent? (4) for terminal flows, did cancel() throw and leave the reader busy? (5) for a subscription, is the period in advance or arrears, and is it paused?How can I help you explore Laravel packages today?