Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Billing For Laravel Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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 dearer tier or an added seat is charged for the time left as its own order with its own invoice, after the owner's credit is spent.
  • A cheaper tier or a removed seat leaves the difference as credit that future payments spend first, capped at what that period's payment holds for that stretch.
  • Each tier is valued over its own interval (a monthly tier switched to yearly costs a twelfth of the yearly price for a month).
  • A provider refusal does not roll back the change: it stays open and is collected after the next paid cycle — so reconcile, don't retry blindly.

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.

Gotchas and Tips

  • In-advance collection is a breaking default. A yearly subscriber who used to pay after a year now pays when the period begins — at the next 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.
  • In-advance means mid-period changes cost money now. A swap or seat change produces an immediate order (or credit). If your upgrade UI assumed "charged at renewal", it is now wrong: the user is charged at the moment of change, and a refused charge stays open — surface it, and let reconciliation catch the follow-up.
  • Pausing is not cancelling. A paused subscription grants no access and (on package-billed drivers) extends the current period by the pause length; a cancellation while paused ends it immediately. If access checks only look at 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.
  • Mandate text must match what was shown. 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.
  • Mollie 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".
  • Custom drivers must update 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.
  • Refused refunds no longer throw. If you caught the SDK exception for a refused refund, switch to $result->failed(). Only non-refund-specific errors (auth, rate limit, outage) still throw — keep that catch.
  • Stripe custom domains need 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.
  • A missing Stripe event type is invisible until it isn't. Subscribe endpoints from 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.
  • Unknown account = 404. Account webhook addresses are resolved through 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.
  • Test-mode mismatch fails fast. An account whose test mode disagrees with its API key is refused before anything reaches the provider — good, but it surfaces at first charge, so validate on account save.
  • Account webhooks stay mounted while billing is off, answering 404. That's intentional (no signature leak, no 500s), but it means "billing disabled" looks identical to "account not found" from the provider's side. Check the ledger, not just the status code.
  • Don't mix event surfaces. Install payments raise the install's events; account payments raise only AccountPaymentChanged. If a listener isn't firing for a multi-shop charge, it's almost certainly subscribed to the wrong surface.
  • Environment overrides fail silently by design. 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.
  • Reconciliation is driver-dependent and windowed. It runs only where the driver can read outcomes back; refunds older than 30 days and chargebacks older than 180 are outside its reach. For long-tail disputes, query the provider yourself.
  • Usage grants and grace have an order. Expiring grants are spent before bought prepaid units; a grace debt is repaid from the next grant before any of that grant's units can be used. Scheduling 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.
  • The AI brake is per-provider, per-day, atomic. Two concurrent calls at the cap cannot both pass, so a refusal is authoritative — retry later, don't re-attempt in a loop. Costs live in millionths; compare caps in the same unit and only round at 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.
  • Extension points for out-of-package drivers: 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.
  • Secrets discipline. 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.
  • Debug order for a stuck payment: (1) is the host in 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?
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource