fomvasss / laravel-billing
Universal billing/payments package for Laravel: pluggable payment gateways, subscriptions, webhooks, usage-based pricing
Requires
- php: ^8.3
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/queue: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0
Suggests
- fomvasss/laravel-currency: Currency conversion adapter for CurrencyConverterContract when a gateway doesn't support the Price's currency
Provides
None
Conflicts
None
Replaces
None
README
Universal billing/payments package for Laravel: pluggable payment gateways, one-time payments and subscriptions with trials, usage-based pricing, webhook processing.
Built-in gateways: LiqPay, WayForPay, Monobank Acquiring, Stripe, Hutko. Add your own with a single Billing::extend() call — no core changes required.
Requirements
- PHP ^8.3
- Laravel ^12 | ^13
- Database: MySQL/MariaDB, PostgreSQL or SQLite — no raw SQL anywhere, and the test suite runs against both SQLite and PostgreSQL
- Octane/long-running workers (Swoole, RoadRunner, FrankenPHP): supported — the package keeps no per-request state in memory. The singleton
BillingManageronly holds class-name registries written at boot; everydriver()call builds a fresh instance and resolves credentials (including per-tenant ones) at call time, and all caching goes through theCachestore, never in-process statics. Just follow the documented pattern: register custom gateways in aServiceProvider::boot(), not mid-request
Installation
composer require fomvasss/laravel-billing
Publish this package's own migrations — in groups, so you only get the tables you actually use. billing-migrations-core (the payments and webhook_calls tables) is the only one everyone needs:
php artisan vendor:publish --tag=billing-migrations-core php artisan vendor:publish --tag=billing-migrations-subscriptions # only if you use Plan/Price/Subscription php artisan vendor:publish --tag=billing-migrations-payment-methods # only if you use saved cards/tokens php artisan migrate
Re-running a vendor:publish command is safe — files are copied under fixed names, so an already-published migration is skipped, not duplicated.
Publish the config file if you need to change defaults (return URLs, debug logging, grace period, etc.):
php artisan vendor:publish --tag=billing-config
Quickstart — the fake gateway
No bank account needed to try the full flow locally. fake is registered automatically in local/testing environments:
use Fomvasss\Billing\BillingManager; use Fomvasss\Billing\Enums\{PaymentStatus, PaymentType}; use Fomvasss\Billing\Models\Payment; $payment = Payment::create([ 'status' => PaymentStatus::Pending, 'type' => PaymentType::Charge, 'gateway' => 'fake', 'amount' => 10000, // minor units — 100.00 'currency' => 'UAH', 'payable_type' => Order::class, 'payable_id' => $order->id, 'billable_type' => $order->user::class, 'billable_id' => $order->user->id, ]); $result = app(BillingManager::class)->charge($payment); return redirect($result->url); // a local page with "Paid"/"Rejected" buttons
Clicking a button POSTs straight to the real, registered webhook endpoint — the same signature-check → ProcessWebhookJob → event pipeline a real gateway would go through, not a shortcut.
Configuring a real gateway
The published config already stubs all five built-in gateways — just fill in the .env values for the ones you use:
MONOBANK_TOKEN= LIQPAY_PUBLIC_KEY= LIQPAY_PRIVATE_KEY= WAYFORPAY_MERCHANT_ACCOUNT= WAYFORPAY_MERCHANT_DOMAIN= WAYFORPAY_SECRET_KEY= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= HUTKO_MERCHANT_ID= HUTKO_SECRET_KEY=
Leave the rest alone — an unset gateway stays unconfigured and only ever errors if something actually tries to charge through it.
Driver-level debug logging (AbstractGateway::log(), via the default log channel) is off by default — BILLING_DEBUG=true for dev/staging while wiring up a gateway; never leave it on in production, since a driver may log raw request/response data including tokens.
Same list at runtime, if you're building a settings UI rather than reading a file — every driver has a static credentialFields(), callable straight on the class, no instance/credentials needed:
use Fomvasss\Billing\Gateways\Monobank\MonobankGateway; MonobankGateway::credentialFields(); // [ // ['name' => 'token', 'type' => 'text', 'secret' => true, 'help' => 'X-Token з кабінету мерчанта...'], // ['name' => 'link_ttl_minutes', 'type' => 'number', 'secret' => false, 'help' => 'TTL посилання на оплату, хв...'], // ]
name is the config key (config('billing.gateways.monobank.token')), secret marks it as sensitive (mask it in a settings UI), help explains where to get the value. Same data for every registered gateway at once, without importing each driver class:
use Fomvasss\Billing\Facades\Billing; Billing::gateways(); // ['monobank' => ['label' => 'Monobank Acquiring', 'currencies' => [...], 'credential_fields' => [...], 'webhook_url' => '...', 'webhook_requires_dashboard_setup' => false, 'capabilities' => [...]], ...] Billing::gateway('monobank'); // just that one gateway's entry, or null if not registered
webhook_url is that gateway's exact callback URL, and webhook_requires_dashboard_setup tells a settings UI whether to show it with a "paste this into the gateway's dashboard" hint — most gateways don't need any manual setup at all (see "Setting up webhooks" below).
Test the connection — a live, side-effect-free probe of credentials + API reachability, for a settings-UI "test connection" button or a monitoring cron (non-zero exit when anything is down):
Billing::health('monobank'); // GatewayHealth { ok: true, message: 'My Shop LLC', latencyMs: 179.2 }
php artisan billing:health # table of all health-capable gateways, exit 1 if any is down php artisan billing:health monobank # a single one
All built-in gateways support it (capabilities.health in gateways()). It validates credentials and reachability right now — never a guarantee about the next charge.
Dynamic per-tenant credentials (instead of one static config array) — bind your own resolver:
$this->app->bind(\Fomvasss\Billing\Contracts\CredentialResolverContract::class, MyCredentialResolver::class);
Multi-merchant setups work in both directions out of the box. Outgoing calls resolve credentials from the billable's tenantId(); incoming webhooks can't do that — a webhook has to pick a secret before it can verify anything — so the tenant rides in the callback URL as ?tenant={id}, added automatically at charge time whenever the billable has one, and read back by every built-in validator. The hint is untrusted by construction and safe anyway: it only selects which secret to verify against, so a forged one picks the wrong secret and fails the signature check. Custom validators get it the same way:
use Fomvasss\Billing\Support\WebhookTenant; $credentials = app(CredentialResolverContract::class)->resolve('mygateway', WebhookTenant::fromRequest($request));
Payable and Billable
payable is what's being paid for (an Order, a Subscription renewal cycle); billable is who's paying. Both are polymorphic — any Eloquent model works with payable, but a billable model needs a tenantId() method (used for dynamic per-tenant credential resolution):
use Fomvasss\Billing\Concerns\Billable as BillableConcern; use Fomvasss\Billing\Contracts\Billable; class Organization extends Model implements Billable { use BillableConcern; // default tenantId(): null — override below if you need multi-tenancy public function tenantId(): ?string { return (string) $this->id; } }
The trait also gives the model the consumer-side accessors, so you rarely need the package models' forBillable() scopes directly:
$organization->payments; // morphMany — chain scopes: ->payments()->paid() $organization->subscriptions; $organization->paymentMethods; $organization->defaultPaymentMethod; // the saved card renewals charge (per gateway — see below) $organization->defaultPaymentMethodFor('monobank'); $organization->activeSubscription(); // same "entitled right now" definition as isActive() $organization->activeSubscription('pro'); // narrowed by Plan code $organization->hasActiveSubscription('pro'); // the gate/middleware one-liner
One nuance: is_default is tracked per gateway, so a customer with cards on two gateways has two defaults — defaultPaymentMethod (the property) returns one of them, defaultPaymentMethodFor() is the precise pick.
Charging
$result = app(BillingManager::class)->charge($payment, new ChargeOptions( description: 'Order #1042', customerEmail: $order->user->email, successUrl: route('order.thanks', $order), )); return redirect($payment->payment_url);
charge() writes external_id/payment_url/payment_url_expires_at back onto $payment — safe to call again on the same Payment once the link expires (each driver decides its own TTL, via its link_ttl_minutes config key — e.g. MONOBANK_LINK_TTL_MINUTES, default 60 min, 1440 for WayForPay/Hutko; Stripe has no such key, its Checkout Session reports its own expires_at). payment_url is always a plain, redirectable link, no matter which gateway: even LiqPay, whose checkout page only accepts a client-submitted form, gets one — the form is cached and served through a package-owned page that submits it for you.
If you need the raw driver result instead (building your own API response for a SPA, say): $result->url is set for every gateway except LiqPay, which sets $result->form (['action' => ..., 'fields' => [...]]) instead — POST those fields to that action yourself.
Return pages
Where the customer's browser lands after checkout. Configure your final pages once — plain app routes or a frontend/SPA URL on another origin:
// config/billing.php 'return_urls' => [ 'success' => 'https://app.example.com/checkout/success', 'failed' => 'https://app.example.com/checkout/failed', ],
The gateway itself is pointed at the package's own return route, which then 303-redirects to your page with ?payment={id} appended — so the page knows which payment to look up. That intermediate hop exists for two practical reasons: WayForPay and Hutko return the customer via an auto-submitted POST (the package route accepts it without any CSRF exceptions on your side, and the 303 turns it into a plain GET on your page), and a SPA frontend can't be a POST target at all.
Need more than the payment id on the final URL — an order number, say? ChargeOptions::$returnParams travels through the hop and lands on your page as query params:
Billing::charge($payment, new ChargeOptions( returnParams: ['order' => $order->number], )); // → https://app.example.com/checkout/success?order=1042&payment={id}
Display hints only — like everything else on this page, never trust them as payment state.
It also fires CheckoutReturned($payment, $outcome, $data) — an analytics/UX hook only. The browser coming back proves nothing (and may never happen): read the payment state from your DB ($payment->isPaid()), show "processing" while the webhook hasn't landed yet, and never fulfil an order from this event.
One more reason the success page must check the DB: success/failed name the return slot, not the verdict — and only Stripe actually has two slots (success_url/cancel_url). Monobank, LiqPay, WayForPay and Hutko have a single return URL, so their customers come back through the success slot whatever happened — a declined card lands on your success page too. Show the real state (isPaid()/isFailed()/pending) there, or a declined customer reads "thank you for your purchase".
A per-charge ChargeOptions(successUrl: ..., failUrl: ...) bypasses the whole mechanism — the URL (with any query params of your own, e.g. an order number) goes to the gateway as-is. If you do that with WayForPay/Hutko, remember their POST-style return is now yours to handle. Monobank has a single redirectUrl for both outcomes (its separate successUrl/failUrl are off by default and enabled per merchant by support), so there failUrl is ignored — the customer lands on successUrl either way, and the payment's real status comes from the webhook as always.
Permanent payment link
route('billing.pay', $payment) is the URL safe to put in an email or invoice — unlike payment_url, it never goes stale:
- pending with a live checkout link → redirects straight to the gateway;
- expired,
failedorcanceled→ a fresh checkout is issued viacharge()on the fly, then redirected to (the old gateway-side invoice is simply left to expire); - already
paid→ lands on yourreturn_urls.successpage with?payment={id}.
Every visit fires PaymentLinkOpened($payment) — an analytics/sales signal ("opened the invoice twice, never paid"), nothing more.
Re-issuing builds its options through ReissueChargeOptionsContract, empty by default: per-charge extras from the original call (saveCard, description, raw, ...) were never stored and are not guessed, and receipt items only auto-fill from a HasReceiptItems payable. Two of those omissions are not cosmetic — saveCard (no card token means the subscription can never renew, and nothing complains) and the fiscal basket. Bind your own resolver to carry the original intent across:
$this->app->bind(ReissueChargeOptionsContract::class, MyReissueOptions::class); class MyReissueOptions implements ReissueChargeOptionsContract { public function resolve(Payment $payment): ChargeOptions { return new ChargeOptions(saveCard: true, description: ..., receiptItems: [...]); } }
Re-issues are serialized per payment with a cache lock: this URL is public and unauthenticated by design, so it does get opened concurrently (a double click, a mail client prefetching links), and two re-issues would leave two live invoices on the gateway for one row. The second visitor re-reads the link the first one stored rather than issuing its own. Same cache-store caveat as refunds above.
Manual/offline payments
No driver is required for cash or bank-transfer payments — just create the row directly:
Payment::create([ 'status' => PaymentStatus::Paid, 'type' => PaymentType::Charge, 'gateway' => null, // or a free-text label like 'cash' — not registered via extend() 'amount' => 10000, 'currency' => 'UAH', 'payable_type' => Order::class, 'payable_id' => $order->id, 'billable_type' => $order->user::class, 'billable_id' => $order->user->id, ]);
paid_at is stamped automatically the moment status becomes paid.
Payment numbers
A UUID is a terrible thing to read over the phone — payments.number is the human-facing reference for receipts, emails and support ("payment PAY-2026-000123"), unique-indexed, with Payment::findByNumber(). The package never generates it, because numbering schemes are project-specific (global sequence, per-order suffix, yearly reset, per-tenant) — assign yours once, in a hook:
// AppServiceProvider::boot() Payment::creating(function (Payment $payment) { // PaymentSequence is yours — whatever your project uses to hand out numbers. $payment->number ??= 'PAY-' . now()->format('Y') . '-' . str_pad((string) PaymentSequence::next(), 6, '0', STR_PAD_LEFT); });
Who started the charge
"Why did money leave my account?" is a question about the initiator, not the mechanism — and a payment row cannot answer it after the fact. payments.initiation records it at charge time:
manual |
automatic |
|
|---|---|---|
| written by | charge() |
chargeWithMethod() |
| means | a person was there | nobody was — a scheduled renewal, a dunning retry |
$payment->isManual(); // a person started it $payment->isAutomatic(); // the scheduler did $payment->initiation; // PaymentInitiation|null
The defaults follow the usual pair — a checkout somebody opened, a renewal nobody was present for — but the two can disagree, so either call takes an override:
// A one-click "pay with the saved card" button: off-session code path, person right there. Billing::chargeWithMethod($payment, $method, new ChargeOptions( initiation: PaymentInitiation::Manual, ));
The column is nullable and never guessed: a payment your own code created without going through either method has no initiation, and both helpers answer false for it — unknown is not a claim either way. Rows written before you upgraded stay null too; backfill them yourself only if you can tell the two apart in your own data.
Gateway fee and net amount
amount is always what the customer paid — refund caps, webhook amount verification and reconciliation all depend on that, so nothing ever rewrites it. What the merchant actually receives lives next to it:
payments.fee— the gateway's commission, minor units, same currency asamount. Drivers parse it from the payment callback where the gateway reports it: Monobank (paymentInfo.fee), LiqPay (receiver_commission), WayForPay (fee), Hutko (fee). Stripe doesn't include the fee in its webhook (it lives on the balance transaction, a separate API object) —feestaysnullthere.$payment->netAmount()—amount - fee, ornullwhile the fee is unknown. Derived, never stored.
null genuinely means "unknown": the package never guesses a commission, while a reported 0 records as "known, zero". If you'd rather book your own commission policy (a flat percent you've agreed to absorb, different rates for foreign cards) — the column is yours to write, and PaymentSucceeded fires after the driver has already filled whatever the bank reported:
// Fill only where the gateway stayed silent — or overwrite unconditionally, your call Event::listen(function (PaymentSucceeded $event) { $payment = $event->payment; if ($payment->fee === null) { $percent = match ($payment->gateway) { 'stripe' => 2.9, default => 1.3, }; $payment->update(['fee' => (int) round($payment->amount * $percent / 100)]); } });
Refunds
Billing::refund() is the entry point — it makes the gateway call and records what happened: a child Payment row (type=refund, linked via parent_payment_id) plus a PaymentRefunded event carrying that row. Full refund by default, partial via Money; cumulative refunds can never exceed the original charge:
use Fomvasss\Billing\Support\Money; $refund = Billing::refund($payment); // the unrefunded remainder, in full $refund = Billing::refund($payment, new Money(2500, 'UAH')); // partial $payment->refundedAmount(); // minor units, sums all paid refund rows
A refund row is only ever written for money that is actually on its way back: a gateway that refuses the refund throws a BillingException (they all answer a refusal with HTTP 200 and a status field, so this isn't something ->throw() would catch — Hutko has two such fields, response_status for a rejected request and reverse_status for a declined reversal), and refundedAmount() counts soft-deleted rows too — hiding a refund row must not re-open room to refund the same amount twice.
Concurrent calls are serialized with a cache lock (billing:refund:{id}): the remainder is read, checked and written by three separate statements, so two calls racing on the same payment would otherwise both pass the check against the same stale total. The second caller gets a BillingException rather than sending money. The lock is only as wide as your cache store: redis/memcached/database cover every process on every server; file locks properly across processes on one machine (it uses flock()) but not across app servers, each of which has its own cache directory; array is per-process and protects nothing (it's the testing store).
Supported where the gateway has a refund API: Monobank, LiqPay, Stripe, Hutko (RefundsPayments — check Billing::gateways()[$name]['capabilities']['refunds']). WayForPay is the exception — no reachable refund endpoint is documented for it, so its refunds happen in the bank's own dashboard (and come back as the reversal webhook below).
Refunds issued outside the package
Money also goes back without Billing::refund() — someone refunds from the gateway's dashboard, or a cardholder disputes the charge. Those arrive as webhooks, and where the payload is unambiguous the package records them exactly like its own refunds: a child row, PaymentRefunded, refundedAmount() kept honest. The gateway's own running total is what's used, so a re-delivered or out-of-order callback settles to the same number instead of stacking, and the callback echoing a refund you issued adds nothing. PaymentRefunded fires exactly once per refund row, whoever recorded it — the echo of your own Billing::refund(), a gateway re-delivery and a queue retry of the same job all resolve against the same per-row dedup claim.
| Gateway | Reversal webhook | Recorded |
|---|---|---|
| Stripe | charge.refunded |
yes, from amount_refunded (the row references the Charge — the event carries no per-refund id) |
| Monobank | invoice reversed |
yes, from cancelList |
| Hutko | the purchase callback again, with reversal_amount set |
yes, from reversal_amount (the order's running total) |
| LiqPay | reversed |
yes, from refund_amount |
| WayForPay | Refunded / Voided |
yes, from amount (per reversal, not a total) |
WayForPay is the odd one out: its callback reports each reversal's own amount rather than a running total (verified against a live test merchant — a 2 UAH order refunded 1 UAH twice produced two callbacks reading amount: 1, while the purchase itself read amount: 2; the wiki only ever describes the field as "Amount of order"). With no total to settle against, a re-delivery is caught by the reversal's identity instead — and the only field distinguishing those two callbacks was processingDate, so that's what identifies one. Known edge: two reversals of the same amount inside one second are indistinguishable and collapse into a single recorded row.
Anything a driver recognizes as a reversal but can't put an amount on is logged rather than dropped — watch for a reversal was reported for a payment but not recorded and record those by hand.
One caveat on LiqPay: its docs describe refund_amount only as "Сума повернення". The field beside it is refund_date_last — explicitly the last refund's date — and this one carries no such qualifier, so it's read as the order's running total. For a single refund (the usual dashboard case) both readings give the same number; if it turns out to be per-reversal after all, a second partial refund would be under-recorded rather than double-counted.
Flow
Three flows cover everything the package does with money. (The machinery behind them — registries, the exact webhook pipeline order, dedup mechanics, who writes which columns — is in docs/architecture.md.) In all of them the same rule holds: the webhook (or its polling fallback) is the only thing that ever changes Payment.status — anything the browser does is UX.
1. One-off checkout (customer present, redirect)
sequenceDiagram
actor Customer
participant App as Your app
participant Billing as BillingManager
participant Driver as Gateway driver
participant Bank as Payment gateway
App->>Billing: charge($payment, $options)
Billing->>Driver: charge($payment, $options)
Driver->>Bank: create checkout
Bank-->>Driver: checkout URL / form
Driver-->>Billing: PaymentResult
Billing-->>App: external_id/payment_url written onto $payment
App-->>Customer: redirect to $payment->payment_url
Customer->>Bank: pays
par Browser return — UX only
Bank-->>Customer: send browser to billing/return/{payment}/{outcome} (GET or POST)
Customer->>App: package return route
Note over App: CheckoutReturned event fires
App-->>Customer: 303 → return_urls.* + ?payment={id} (+ returnParams)
and Webhook — the source of truth
Bank->>App: POST /billing/webhooks/{gateway}
Note over App: SignatureValidator verifies,<br/>WebhookCall stored, ProcessWebhookJob queued
App->>Driver: handleWebhook($webhookCall)
Driver-->>App: WebhookResult (Payment.status updated, amount verified)
Note over App: dedup claimed on webhook_calls
App->>App: PaymentSucceeded / PaymentFailed
App-->>App: your listener reacts (fulfil order, etc.)
end
Loading
The two halves of the par block are independent and unordered — the webhook often lands before the customer's browser is even back. The return page should read the payment state from the DB and show "processing" until the webhook arrives.
2. Recurring charge (no customer present, saved card)
What billing:process-recurring-charges does every minute — also exactly what happens when you call chargeWithMethod() yourself (overage, trial conversion with a saved card):
sequenceDiagram
participant Cron as Scheduler (every minute)
participant Cmd as process-recurring-charges
participant Driver as Gateway driver
participant Bank as Payment gateway
participant Listener as Built-in listener
Cron->>Cmd: run
Note over Cmd: 1. cancels_at reached → canceled, SubscriptionCancelled<br/>2. skip if a renewal Payment is still pending (no double charge)<br/>3. skip until next_retry_at (dunning pacing)
Cmd->>Cmd: create pending Payment (payable = Subscription)
Cmd->>Driver: chargePaymentMethod($payment, $method)
Driver->>Bank: off-session charge with the saved token
Bank-->>Driver: initiated
Bank->>Listener: webhook → PaymentSucceeded / PaymentFailed (same pipeline as flow 1)
alt paid
Note over Listener: status=active, period +1 interval,<br/>attempts/grace reset → SubscriptionRenewed
else failed
Note over Listener: status=past_due, attempts+1,<br/>next_retry_at per retry_intervals → SubscriptionPaymentFailed<br/>after max_recurring_attempts → canceled + SubscriptionCancelled
end
Loading
3. Lost webhook (reconciliation fallback)
sequenceDiagram
participant Cron as Scheduler (every 15 min)
participant Cmd as reconcile-pending-payments
participant Driver as Gateway driver
participant Bank as Payment gateway
Note over Cmd: Payment pending longer than reconcile_after_minutes —<br/>webhook lost, or a status (expired) that never gets one
Cron->>Cmd: run
Cmd->>Driver: checkStatus($payment)
Driver->>Bank: poll payment status
Bank-->>Driver: paid / failed / expired
Note over Cmd: Payment updated, the SAME events fire through the shared<br/>dedup — a late real webhook can't double-dispatch afterwards
Loading
Gateways without a status endpoint skip the poll: a pending payment older than reconcile_after_minutes is marked canceled as a dead checkout (that config value, not the checkout link's own TTL, is what decides).
Webhooks
One route (POST /billing/webhooks/{gateway}) handles every gateway, resolved at request time through BillingManager's own registry — nothing to configure by hand. Incoming webhooks are signature-verified, stored (billing_webhook_calls), queued (ProcessWebhookJob), and turned into one of these events:
| Event | Fires when |
|---|---|
PaymentSucceeded / PaymentFailed |
A Payment's status resolves to a terminal state |
PaymentRefunded |
Billing::refund() created a refund row (see "Refunds") |
PaymentCanceled |
The charge will never complete — the gateway voided/expired it, or reconciliation wrote off a checkout nobody finished. Not the same as PaymentFailed: no card was ever refused, so it's usually not worth emailing about |
SubscriptionRenewed / SubscriptionPaymentFailed / SubscriptionCancelled |
The outcome of a renewal charge, handled by the package's own listener (period advanced / dunning / cancelled after max_recurring_attempts or at cancels_at). SubscriptionRenewed::$previousStatus says which paid period this is — see "Welcome, renewed, or recovered" |
SubscriptionPeriodEnding |
From billing:send-period-notices, at each period_ending_notices interval before current_period_ends_at (off by default) — $event->willRenew says whether the period ends in a charge or in the subscription ending, $event->notice which reminder it is |
SubscriptionAccessSuspended |
Only when grace_access resolves false — fires once, the moment a failed renewal cuts isActive() to false immediately instead of granting the grace window |
SubscriptionCreated |
Native-subscription gateways only — no built-in driver dispatches it yet |
TrialWillEnd |
From billing:expire-trials, at each trial_ending_notices interval before trial_ends_at (default ['3 days']; e.g. ['7 days', '3 days', '1 day'] for yearly plans, ['1 hour', '15 minutes'] for hourly rentals) — once per subscription per notice, $event->notice says which one fired |
SubscriptionPaused / SubscriptionResumed |
Local-only, via $subscription->pause()/resume() — never gateway-driven |
CheckoutReturned |
The customer's browser came back from checkout (see "Return pages") — UX/analytics only, never proof of payment |
PaymentLinkOpened |
Someone opened the permanent pay link (billing.pay, see "Permanent payment link") — analytics only |
PaymentMethodAttached / PaymentMethodDetached |
A saved card/token is attached or removed |
UsageLimitReached |
Subscription::reportUsage() crosses price.included_units |
SubscriptionQuotaReset |
a quota cycle of its own (price.quota_interval) rolled over — the allowance is back |
Listen for them the usual way:
Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) { $event->payment->payable; // your Order, Subscription, etc. });
Setting up webhooks in the gateway dashboards
Every gateway's callback URL is https://your-domain/billing/webhooks/{gateway} — ready-made in Billing::gateways()[$name]['webhook_url']. The catch: for most gateways there's nothing to configure — the driver passes the URL in every charge request. webhook_requires_dashboard_setup in Billing::gateways() carries the same answer at runtime, per gateway:
| Gateway | How it gets the URL | Dashboard setup |
|---|---|---|
| Monobank | webHookUrl in every invoice request |
none |
| LiqPay | server_url in every payment |
none |
| WayForPay | serviceUrl in every Purchase/Charge |
none |
| Hutko | server_callback_url in every request |
none |
| Stripe | Pre-registered endpoints only (Dashboard or one API call) | required |
Stripe — the package can register the endpoint for you:
php artisan billing:stripe-register-webhook # creates the endpoint, prints STRIPE_WEBHOOK_SECRET php artisan billing:stripe-register-webhook --fresh # domain/tunnel changed, or the event list grew — delete & re-create (new secret)
The secret is shown only at creation (Stripe never returns it again) — paste it into .env right away. Equivalent manual ways, if you prefer:
- Dashboard: Developers → Webhooks → Add endpoint with
https://your-domain/billing/webhooks/stripe, subscribe tocheckout.session.completed,checkout.session.expired,payment_intent.succeeded,payment_intent.payment_failed, copy the Signing secret (whsec_...). - One API call — no Dashboard visit at all, the signing secret comes back in the response:
curl https://api.stripe.com/v1/webhook_endpoints -u "sk_test_...:" \ -d url="https://your-domain/billing/webhooks/stripe" \ -d "enabled_events[]=checkout.session.completed" -d "enabled_events[]=checkout.session.expired" \ -d "enabled_events[]=payment_intent.succeeded" -d "enabled_events[]=payment_intent.payment_failed" # → response contains "secret": "whsec_..."
Either way the secret goes into STRIPE_WEBHOOK_SECRET — without it the validator rejects everything (fail-closed). Note the registered URL is fixed on Stripe's side: changing your domain/tunnel means re-creating the endpoint.
Applies to all of them: APP_URL must be your real public URL (route() builds the callback from it), the path must be reachable over HTTPS without basic auth/IP blocks (CSRF is already not an issue — the route lives outside the web group), and on a local machine a bank can't reach you at all — use a tunnel (ngrok/expose) or just the fake gateway, which runs the same pipeline. Every accepted webhook leaves a row in billing_webhook_calls; a 403 in the logs means a signature/secret problem.
What the pipeline guarantees
- Signature validators fail closed. All five webhook routes exist even for gateways you never configured — a route whose gateway has no secret set responds 403 to everything instead of "verifying" against an empty key.
- A paid callback must match the payment's amount and currency. A signed callback whose sum differs (classic case: a stale checkout link paid after the order's amount was edited and
charge()re-issued) does not mark the payment paid — it's logged as a warning and leftpendingfor manual review. Status polling applies the same check, so reconciliation can't quietly accept an hour later what the webhook just refused. - A paid payment is never reverted by a webhook. Gateway deliveries are neither ordered nor unique — an earlier decline can arrive after the success it lost the race to, and a stale link's
expiredcan arrive afterbilling.payre-issued the checkout and the customer paid it. Once aPaymentispaid, any callback (or status poll) claiming otherwise is ignored and logged as a warning; the row keeps itspaid_at, itsexternal_idand its refundability. Re-issuing works in the other direction: afailed/canceledpayment can still becomepaid. - Events are deduplicated per outcome, not per reference. A re-delivered "paid" callback never fires
PaymentSucceededtwice — but "declined, then the customer retries the same checkout and pays" dispatches bothPaymentFailedandPaymentSucceeded, even on gateways that reuse one reference across attempts. The reconciliation command shares the same dedup, so a poll racing a late webhook can't double-dispatch either. - Nothing that moves money is ever retried at the transport level. Laravel's HTTP retry fires on a timeout too, and a timeout says nothing about whether the bank already debited the card — so off-session charges and refunds are sent exactly once on every built-in driver. Stripe is the exception that proves it: it gets an
Idempotency-Key, which is what makes a retry there safe. - A callback for a payment the package doesn't know (another integration on the same merchant account, rows predating the install) is ignored — no failed jobs.
- Stored webhook calls are pruned after
config('billing.webhook.prune_after_days')(default 30) by a dailymodel:prunerun, registered together with the other scheduled commands.
Horizon / Queue
Incoming webhooks are processed by one queued job (ProcessWebhookJob). By default it runs on the app's default connection/queue; give it a dedicated queue so a busy default queue can't delay marking payments paid:
BILLING_QUEUE_CONNECTION=redis BILLING_QUEUE=billing
Example Horizon supervisor — the job is short (the only gateway API call it can make is Stripe's saved-card lookup), so a couple of processes with a short timeout are enough:
'supervisor-billing' => [ 'connection' => 'redis', 'queue' => ['billing'], 'balance' => 'simple', 'minProcesses' => 1, 'maxProcesses' => 4, 'tries' => 3, 'timeout' => 60, ],
If you set BILLING_QUEUE, make sure some worker/supervisor actually consumes that queue — otherwise webhooks are stored but never processed.
The job carries its own $tries = 3 with a 10s / 60s / 300s backoff — a deadlock or a blipped Redis between marking a payment paid and firing PaymentSucceeded must not end as a paid row whose order was never fulfilled, and many apps run their workers at --tries=1. What this means for your listeners:
- The dedup claim and your listeners commit together. They run inside one transaction, so a listener that throws rolls its own writes and the claim back, and the retry re-dispatches the event cleanly. Their DB work doesn't have to be idempotent for that path.
- Non-DB side effects still do. An email sent or an external API called before the throw isn't rolled back, and the retry runs the listener again — guard those with your own idempotency key.
- A queued listener needs
after_commit. Set'after_commit' => trueon the queue connection (orShouldQueue+$afterCommiton the listener), or a worker can pick the listener job up before the transaction it was dispatched in commits. - Exceptions are recorded. After the last retry, the failure is written to
billing_webhook_calls.exceptionalongside the raw payload.
Customizing the webhook route
Both the path and the middleware stack are config-driven — {gateway} must stay somewhere in the path (WebhookController resolves the driver from that segment), everything else is yours:
// config/billing.php 'webhook' => [ 'path' => 'webhook/billing/{gateway}', // your own prefix convention instead of the default billing/webhooks/{gateway} 'middleware' => ['throttle:60,1'], // empty by default — webhook endpoints deliberately skip the `web` group (no CSRF, no session) ],
The route name (billing.webhook) never changes, so AbstractGateway::webhookUrl() and the webhook_url field in Billing::gateways() keep resolving correctly regardless of the configured path — nothing else to update when you change it.
Writing your own gateway
Six required methods (PaymentGatewayContract — two instance, four static metadata), everything else opt-in, and one call to register it:
// in your ServiceProvider::boot() — your own project or a satellite package (fomvasss/laravel-billing-mygateway) use Fomvasss\Billing\Facades\Billing; Billing::extend('mygateway', MyGateway::class) ->registerWebhook('mygateway', MyGatewaySignatureValidator::class);
No route to declare and no config file to touch — every gateway shares the single POST /billing/webhooks/{gateway} route, resolved through this registry at request time.
→ Full guide: writing a gateway — the contract method by method, signature validation, the three tokenization shapes, custom webhook acknowledgments, testing without merchant credentials, and the verification pitfalls that cost us real bugs.
Subscriptions
$plan = Plan::create(['code' => 'pro', 'name' => 'Pro']); $price = $plan->prices()->create([ 'gateway' => 'stripe', 'currency' => 'USD', 'amount' => 2900, // $29.00 'pricing_type' => PricingType::Flat, 'interval' => Interval::Month, 'interval_count' => 1, 'trial_days' => 14, ]); $subscription = Subscription::create([ 'status' => SubscriptionStatus::Trialing, 'gateway' => 'stripe', 'price_id' => $price->id, 'billable_type' => $organization::class, 'billable_id' => $organization->id, // trial_ends_at comes from the price's trial_days — pass it explicitly to override. ]);
Pricing types
flat— fixed amount,qty/current_usageignored.licensed—amount × subscription.qty(seats/licenses).metered—amount × subscription.current_usage(pay-as-you-go).
Usage & quotas
included_units/current_usage are orthogonal to pricing_type — a flat price can still carry a quota (e.g. "4,000 AI tokens included per month, fixed price either way"):
$subscription->reportUsage(quantity: 1500, idempotencyKey: "ai-run:{$run->id}"); $subscription->remainingUsage(); // null if the price has no quota at all
UsageLimitReached fires once when cumulative usage crosses included_units — react to it however fits (block further use, notify, or charge an overage via TokenizesPaymentMethod::chargePaymentMethod()).
On a successful renewal, current_usage resets to 0 whenever the price has a quota (included_units set) or is metered — a fresh paid period means a fresh allowance, nothing to reset yourself. Quota-less flat/licensed usage is left untouched: there it's just a counter your app owns.
A quota cycle of its own
By default the allowance lives on the billing period, so a yearly price hands out a year's worth up front. When you sell "pay for a year, get 10,000 units every month", give the price a quota cycle independent of the billing one:
Price::create([ 'interval' => 'year', 'interval_count' => 1, // charged once a year 'quota_interval' => 'month', 'quota_interval_count' => 1, // allowance renews monthly 'included_units' => 10000, ]);
billing:reset-usage-quotas (hourly) zeroes current_usage when subscriptions.quota_period_ends_at passes, moves the boundary to the next one and fires SubscriptionQuotaReset. remainingUsage() needs no changes — it reads the same counter either way.
quota_intervalis only honoured together withincluded_units: on its own there is nothing to reset, so it is ignored rather than zeroing a counter your app owns.- Unused allowance expires, it does not accumulate: a gap of three missed cycles (a scheduler outage, say) grants one fresh allowance, not three.
- A paid renewal restarts the cycle from that moment — the allowance was just zeroed, so the next reset is a full cycle away.
- Provider-managed subscriptions are reset too, unlike every other scheduled command here: a quota reset touches no gateway and no money, and the counter is the package's own bookkeeping whoever runs the billing.
- Paused and ended subscriptions keep their stale boundary and get a fresh allowance when they resume — quota for a subscription nobody is paying for would be a quiet giveaway.
Pause / resume / cancel
$subscription->pause(); // local only — no gateway call, no event to the bank $subscription->pause(now()->addWeek()); // auto-resumes via billing:expire-pauses $subscription->resume(); // manual resume, any time, ends a pause early $subscription->cancel(); // at period end (default) $subscription->cancel(atPeriodEnd: false); // immediately $subscription->swapPlan($newPrice);
A pause with no $until is indefinite — only an explicit resume() ends it. isActive() is false while paused — except once pause_ends_at has passed, where access comes back at that timestamp rather than waiting for billing:expire-pauses to write the status.
cancel() at period end only stamps cancels_at — the actual status flip (and the guarantee the customer is not charged for another period) happens in billing:process-recurring-charges when that moment passes. In other words: period-end cancellation requires the schedule to be enabled, same as auto-renewal itself.
Recurring charges, reconciliation, trial expiry
Five artisan commands, off by default (billing.schedule.enabled, since they touch money and subscription state):
// config/billing.php 'schedule' => ['enabled' => true],
| Command | Runs | What it does |
|---|---|---|
billing:process-recurring-charges |
every minute | First finalizes subscriptions whose cancels_at has passed (status → canceled, SubscriptionCancelled fires) so a period-end cancellation is never billed again. Then finds subscriptions where current_period_ends_at <= now() and charges the saved PaymentMethod via chargePaymentMethod() — unless an earlier renewal Payment is still pending (webhook not yet resolved), which blocks a second charge for the same period. Only initiates the charge — the outcome arrives later through the normal webhook pipeline, handled automatically: the period advances on PaymentSucceeded; on PaymentFailed the subscription goes past_due and is retried on the retry_intervals ladder (spaced out, not every scheduler run) until max_recurring_attempts is reached, then SubscriptionCancelled. With the defaults that's the renewal charge, then +6h, +24h, +48h, then cancelled — a card that just failed is worth retrying soon, the third failure in a row is worth waiting on. No saved card to charge (never tokenized, or detached since the last renewal) gets the identical grace/retry treatment via Subscription::recordRenewalFailure() — it doesn't stall in active waiting for a card that never arrives. So does an attempt that never reached the gateway at all (timeout, gateway 5xx): the Payment is written off as failed rather than left pending where it would block this subscription's renewals for good — if the charge did land at the bank after all, its webhook still arrives long before the next retry and flips the row to paid. A renewal that owes nothing — a metered period with no usage, a licensed one down to zero seats — advances the period directly instead of attempting a zero debit every gateway rejects. |
billing:reconcile-pending-payments |
every 15 min | Fallback for a Payment stuck pending because a webhook was lost, or a gateway expired status that never gets its own webhook. Only looks at payments older than config('billing.reconcile_after_minutes') (default 60 min) — that cutoff already delays how soon a stuck payment qualifies, which is why this runs more often than the other two, not hourly like them. A failure on one payment is reported and skipped, never blocks the rest. |
billing:expire-trials |
hourly | Dispatches TrialWillEnd at each configured trial_ending_notices interval (once per subscription per notice; when several become due at once only the closest fires), then moves trialing subscriptions past trial_ends_at to ended. Converting a trial to paid is a normal chargeWithMethod() call, same as any renewal (see "Free trial period" in Recipes). |
billing:send-period-notices |
hourly | Dispatches SubscriptionPeriodEnding at each configured period_ending_notices interval before a paid period's current_period_ends_at — the advance "we'll charge your card on the 14th" notice, or, when the customer has already cancelled (cancels_at set), the "your access ends on the 14th" one; $event->willRenew tells the two apart. Same once-per-notice rule as the trial notices, per period, and a successful renewal clears the markers so the next period notifies again. Off unless the list is set — active, package-managed subscriptions only, since a trialing one has its own notices and a past_due one is already being dunned. |
billing:expire-pauses |
hourly | Resumes paused subscriptions whose pause_ends_at (set via pause($until)) has passed. Access itself already came back at that timestamp — this writes the status down and fires SubscriptionResumed. Indefinite pauses (pause_ends_at null) are untouched. |
billing:reset-usage-quotas |
hourly | Zeroes current_usage for prices with a quota cycle of their own (prices.quota_interval) once quota_period_ends_at passes, and fires SubscriptionQuotaReset. Unlike the others it does not skip provider-managed rows — the allowance is local bookkeeping. Prices without quota_interval are untouched. |
model:prune (BillingWebhookCall) |
daily | Deletes stored webhook calls older than webhook.prune_after_days (default 30). |
None of this fires on its own — Schedule::command()/->hourly() etc. just register with Laravel's own scheduler, which still needs the standard system cron entry running php artisan schedule:run every minute (the usual Laravel deployment requirement, nothing package-specific).
Want a different cadence? The built-in schedule is just a sane default — disable it and register the commands yourself at any frequency; they're idempotent by design (pending-renewal guard, next_retry_at, webhook-shared dedup), and the built-in entries already run withoutOverlapping():
// config/billing.php: 'schedule' => ['enabled' => false] // e.g. slow the charging down to a nightly batch, speed reconciliation up: Schedule::command('billing:process-recurring-charges')->dailyAt('03:00')->withoutOverlapping(); Schedule::command('billing:reconcile-pending-payments')->everyFiveMinutes()->withoutOverlapping(); Schedule::command('billing:expire-trials')->hourly(); Schedule::command('billing:send-period-notices')->hourly(); Schedule::command('billing:expire-pauses')->hourly(); Schedule::command('billing:reset-usage-quotas')->hourly();
Keep withoutOverlapping() on the money-touching commands, and add onOneServer() if the scheduler runs on several servers.
Fiscalizing a renewal
A renewal is the one charge you never call yourself — no request, no ChargeOptions, and a payable (the Subscription row) that carries no HasReceiptItems basket. Where a fiscal receipt is required, that would leave the first payment fiscalized and every renewal after it bare. RenewalChargeOptionsContract is where a renewal's options come from instead.
The cheap way in, for a flat subscription that just needs a line on the receipt:
// config/billing.php 'renewal' => ['receipt_items' => true],
Every renewal now carries a one-line basket — the payment's full amount, qty 1, named after the plan (or after prices.meta['receipt_name'], if you'd rather write "Підписка «Pro», 1 міс" than "Pro"), and carrying the article code from prices.meta['receipt_sku'] when it is set (Monobank puts it in basketOrder.code; omitted entirely when the price has none). That is as far as a generic implementation can go without inventing data: licensed charges whole seats and metered a fractional quantity, so splitting the total into qty × unitAmount would round off a kopiyka and fail the receipt-total check.
For anything richer — per-seat lines, tax codes, UKTZED, LiqPay's rro_info — bind your own. It also fills in what a renewal otherwise has no source for: the description, the customer's email, and the IP (LiqPay requires one for an off-session paytoken charge; Hutko sends 127.0.0.1 without it):
// AppServiceProvider::register() $this->app->bind(RenewalChargeOptionsContract::class, fn () => new class implements RenewalChargeOptionsContract { public function resolve(Subscription $subscription, Payment $payment): ChargeOptions { $organization = $subscription->billable; return new ChargeOptions( receiptItems: [[ 'name' => "Підписка «{$subscription->price->plan->name}», 1 міс", 'qty' => 1, 'unitAmount' => $payment->amount, 'sku' => $subscription->price->plan->code, ]], customerEmail: $organization->billing_email, customerIp: $organization->last_ip, description: "Продовження підписки #{$subscription->id}", ); } });
The gateways take it from there exactly as they do on a first payment: Monobank as basketOrder, WayForPay as productName[]/productPrice[]/productCount[], Hutko as its RRO reservation_data. LiqPay has no neutral basket field at all — put rro_info in ChargeOptions::$raw instead. Stripe has no basket on an off-session PaymentIntent, so a basket passed there is simply unused.
Two things worth knowing before you write one:
- The basket must add up to
$payment->amount— same check as any other charge, and it runs before the gateway is called. - A resolver that throws fails that renewal: the
Paymentis written off asfailedand the subscription enters the normal dunning cycle. That is deliberate (apendingpayment left behind would block this subscription's renewals for good), but it does mean a bug in the resolver duns real customers — keep it to reading what's already on the models, with no outbound calls.
Who runs the renewal: package-managed vs provider-managed
Everything above describes package-managed subscriptions — the package's scheduler charges the saved card, paces the dunning, expires the trials. That's the only mode the built-in drivers produce, because none of the Ukrainian gateways host subscriptions natively.
Some gateways can own the whole lifecycle themselves (Stripe Billing is the canonical example): a driver implementing SubscriptionGatewayContract creates the subscription on the provider's side, and from then on the provider renews, retries and converts trials, reporting back through webhooks the driver maps to the normal subscription events. Such a subscription carries the provider's reference in subscriptions.external_id — and that column is the ownership marker, not just "some id": Subscription::isProviderManaged() reads it, and every scheduled command (process-recurring-charges, cancellation finalizing, expire-trials, trial notices) skips provider-managed rows entirely, so the package never charges, cancels or notifies in a race with the provider.
The split is per subscription, not per gateway — deliberately, since a gateway like Stripe supports both models at once: the same merchant can run B2C plans through Stripe Billing (proration and invoices for free) and custom B2B deals package-managed on the very same driver. Rule of thumb: external_id on a subscription must only ever be written by a SubscriptionGatewayContract driver — set it by hand and you're telling the package "hands off, the provider drives this one".
Statuses and history
A Subscription is one row for its whole life — the first payment flips a trialing row to active, renewals move current_period_ends_at forward, dunning takes it through past_due and back; a new row only appears if the customer signs up again after canceled/ended.
stateDiagram-v2
[*] --> trialing: registration, free period
[*] --> active: direct paid signup
trialing --> active: first payment (PaymentSucceeded)
trialing --> ended: trial expired without converting
active --> active: renewal paid — period +1 interval
active --> past_due: renewal failed (dunning starts)
past_due --> active: retry paid
past_due --> canceled: max_recurring_attempts exhausted
active --> canceled: cancel() — immediately or at cancels_at
active --> paused: pause()
paused --> active: resume()
Loading
| Status | Meaning |
|---|---|
trialing |
Free period, no card needed — still counts as active for access checks |
active |
Paid and current |
past_due |
A renewal failed; retried on the retry_intervals ladder — isActive() stays true until grace_ends_at, which is always stamped past the next retry |
paused |
Local pause via pause()/resume() — never gateway-driven |
canceled |
Cancelled (immediately, at period end, or by dunning exhausting max_recurring_attempts) |
ended |
Trial expired without converting |
Renewing vs re-subscribing — the package doesn't decide this, which row the payment points to does. A Payment with payable = an existing subscription row is a renewal/reactivation: the built-in listener flips whatever status it finds (trialing, past_due, even canceled) to active and advances the period. canceled/ended rows are never touched automatically — no auto-charges against them — so "coming back" is always your code's move, and the rule of thumb is: within the grace window (past_due) pay against the same row; after canceled/ended create a new row. Two reasons: history stays clean (the old row remains a finished episode), and a period-anchor gotcha — the listener advances the period from current_period_ends_at, which on a long-dead row is months in the past, so a payment against it would produce a "new" period that has already ended (fixable by nulling current_period_ends_at first, but a fresh row simply doesn't have the problem).
What's recorded out of the box: every charge is an immutable Payment row (the full financial history, forever), raw webhooks live in billing_webhook_calls (pruned after prune_after_days), and the subscription row itself keeps the key timestamps (trial_ends_at, cancels_at, pause_ends_at, grace_ends_at, recurring_attempts). What's not recorded: a status-transition log — status is overwritten in place.
If you want that chronology, every transition already fires an event — one listener in your app writes the journal (SubscriptionLog below is your own model, or point the same listener at spatie/laravel-activitylog):
use Fomvasss\Billing\Events\{SubscriptionRenewed, SubscriptionPaymentFailed, SubscriptionCancelled, SubscriptionPaused, SubscriptionResumed, TrialWillEnd}; class LogSubscriptionTransition { public function handle(SubscriptionRenewed|SubscriptionPaymentFailed|SubscriptionCancelled|SubscriptionPaused|SubscriptionResumed|TrialWillEnd $event): void { SubscriptionLog::create([ 'subscription_id' => $event->subscription->id, 'status' => $event->subscription->status->value, 'event' => class_basename($event), // SubscriptionRenewed, TrialWillEnd, ... ]); } } // AppServiceProvider::boot() Event::listen([ SubscriptionRenewed::class, SubscriptionPaymentFailed::class, SubscriptionCancelled::class, SubscriptionPaused::class, SubscriptionResumed::class, TrialWillEnd::class, ], LogSubscriptionTransition::class);
Welcome, renewed, or recovered
SubscriptionRenewed covers three different moments — a trial converting into the first paid
period, an ordinary auto-renewal, and a renewal that finally went through after a failed attempt.
By the time the event fires the subscription is active in all three, so the event carries the
status it came from:
Event::listen(SubscriptionRenewed::class, function (SubscriptionRenewed $event) { $organization = $event->subscription->billable; match ($event->previousStatus) { SubscriptionStatus::Trialing => $organization->notify(new SubscriptionWelcome($event->subscription)), SubscriptionStatus::PastDue => $organization->notify(new SubscriptionRestored($event->subscription)), default => $organization->notify(new SubscriptionRenewalReceipt($event->subscription)), }; });
previousStatus is null when the transition isn't the package's to know — a provider-managed
subscription renewed on the gateway's side and the webhook is the first the package hears of it.
And when your code creates a row that is already active and then charges it, its first payment
looks exactly like a renewal (previousStatus is Active) — you were the one creating the row,
so send the welcome from there. Creating the row as trialing and converting it with a payment
(the "Free trial period" recipe) keeps all three cases unambiguous.
Tokenization / saved cards
All 5 built-in gateways implement TokenizesPaymentMethod — attach a card once, then chargeWithMethod() it any time after (renewals, overage charges, upgrades, ...).
The main path — every gateway, no frontend code: the card is saved as a side effect of the first real charge, and the PaymentMethod just shows up once the customer pays:
// Monobank/LiqPay/Hutko/Stripe need the flag; only WayForPay saves the card regardless Billing::charge($payment, new ChargeOptions(saveCard: true)); // ... customer pays, the PaymentMethod attaches on its own and PaymentMethodAttached fires — nothing else to call
On Stripe this works through the hosted Checkout (setup_future_usage, a per-billable Stripe customer is created/reused automatically) — no Stripe.js involved.
Stripe extra: saving a card without charging (the one thing the UA gateways can't do) — a SetupIntent driven by your frontend:
$customerId = Billing::driver('stripe')->createCustomer($user); // frontend collects a card via Stripe.js/Elements against that customer id, confirms a // SetupIntent, gets back a PaymentMethod id (pm_...) — POST it to your own endpoint $method = Billing::driver('stripe')->attachPaymentMethod($user, ['payment_method_id' => $pmId]); Billing::chargeWithMethod($payment, $method);
Already have a token from somewhere else? attachPaymentMethod($billable, [...]) takes it directly — the array key differs per gateway: payment_method_id (Stripe), card_token (Monobank/LiqPay), rec_token (WayForPay), rectoken (Hutko). detachPaymentMethod($method) removes the saved card — Monobank and Stripe also revoke it at the provider, LiqPay/WayForPay/Hutko just stop using it locally (none of them documents a revocation endpoint for a standalone token).
Either way, chargeWithMethod()/chargePaymentMethod() only initiate the charge — the outcome always arrives through the normal webhook pipeline, same as charge().
Pass customerIp with an off-session charge where you can: LiqPay documents it as required for a paytoken charge (there's no browser behind a renewal to take it from), and Hutko sends it as client_ip. Both fall back to a placeholder without it.
Billing::chargeWithMethod($payment, $method, new ChargeOptions(customerIp: $user->last_login_ip));
Recipes
Everything above is the building blocks; here's how they combine for a few real scenarios. Wider, end-to-end system designs (a SaaS with a token wallet, a store with expenses, hourly rentals, a tariff storefront) live in docs/use-cases.md.
1. Store checkout with fiscal receipt items
Order implements HasReceiptItems — charge() picks it up automatically, no need to pass receiptItems yourself:
class Order extends Model implements Payable, HasReceiptItems { public function receiptItems(): array { return $this->items->map(fn (OrderItem $item) => [ 'name' => $item->product->name, 'qty' => $item->qty, 'unitAmount' => $item->unit_price, // minor units 'sku' => $item->product->sku, ])->all(); } }
What each gateway does with it differs — Monobank (basketOrder), WayForPay (productName[]/productPrice[]/productCount[]), Stripe (line_items) and Hutko (reservation_data, its programmable-RRO fiscal basket) all take it as-is. The exception is LiqPay: its rro_info line items reference goods registered in your LiqPay account by their catalog id — a value this neutral shape has no field for — so pass that one explicitly via ChargeOptions::$raw (see below).
The same auto-fill applies to chargeWithMethod() — an off-session charge (overage, a top-up, the postpaid-ride charge in use-case #7) is fiscalized exactly like a redirect checkout, as long as $payment->payable implements HasReceiptItems. The one place the auto-fill can't reach is a scheduled renewal, whose payable is always the package's own Subscription row: see "Fiscalizing a renewal" below for the hook that covers it.
Whatever the basket comes from, it has to add up to the payment's own amount — a mismatch throws before the gateway is called. This isn't pedantry about fiscal data: Stripe bills the sum of its line items rather than your amount, so a basket that disagrees charges the customer one number while the row says another, and the callback — checked against amount — then refuses to mark it paid.
$payment = Payment::create([ 'status' => PaymentStatus::Pending, 'type' => PaymentType::Charge, 'gateway' => 'monobank', 'amount' => $order->total, // minor units 'currency' => 'UAH', 'payable_type' => Order::class, 'payable_id' => $order->id, 'billable_type' => $order->user::class, 'billable_id' => $order->user->id, ]); Billing::charge($payment, new ChargeOptions( description: "Order #{$order->number}", customerEmail: $order->user->email, )); return redirect($payment->payment_url);
Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) { if ($event->payment->payable instanceof Order) { $event->payment->payable->markAsPaid(); } });
Anything a gateway supports that has no neutral equivalent goes through ChargeOptions::$raw — merged into the request as-is, read only by whichever driver you're charging through, ignored by the rest:
Billing::charge($payment, new ChargeOptions( description: "Order #{$order->number}", raw: [ // LiqPay fiscalization — ids come from your LiqPay account (SCR → Kasa → Goods) 'rro_info' => [ 'items' => $order->items->map(fn ($item) => [ 'id' => $item->product->liqpay_goods_id, 'amount' => $item->qty, 'price' => $item->unit_price / 100, 'cost' => $item->total / 100, ])->all(), 'delivery_emails' => [$order->user->email], ], ], ));
$raw is merged under the driver's own fields, so it can add what the driver doesn't set but never override the amount or the merchant reference the webhook matches on.
2. Subscribe to a 15 GB plan — and how the auto-renewal actually works
$plan = Plan::create(['code' => 'storage-15gb', 'name' => '15 GB storage']); $price = $plan->prices()->create([ 'gateway' => 'stripe', 'currency' => 'USD', 'amount' => 500, // $5.00/month 'pricing_type' => PricingType::Flat, 'interval' => Interval::Month, 'interval_count' => 1, ]); $subscription = Subscription::create([ 'status' => SubscriptionStatus::Active, 'gateway' => 'stripe', 'price_id' => $price->id, 'billable_type' => $organization::class, 'billable_id' => $organization->id, 'current_period_ends_at' => now()->addMonth(), ]);
The first charge tokenizes the card (saveCard: true, see "Tokenization" above). Auto-renewal itself is billing:process-recurring-charges — off by default, so turn on the schedule (config('billing.schedule.enabled', true), see the table above for what it does and when it runs); everything past that (advancing the period, dunning on failure) is already wired up, nothing else to write.
You don't write any of step 3 yourself — it's already wired up. You only need step 1 and a saved PaymentMethod.
3. One-off purchase of extra 5 GB (not part of the subscription cycle)
Not a subscription line item — the package has no "wallet"/addon-balance concept on purpose (see below), so this is just a regular one-off Payment. The part that's easy to get wrong: a Payment alone only tells you who paid and how much, not what for — two different add-ons could even cost the same. Two ways to fix that, pick based on how many one-off purchase types you'll ever have pointing at the same customer:
Payment::$meta — a plain json column, opaque to the package (same idea as Plan::$meta), the simplest option when there's only one kind of one-off purchase:
$payment = Payment::create([ 'status' => PaymentStatus::Pending, 'type' => PaymentType::Charge, 'gateway' => 'stripe', 'amount' => 200, // $2.00 for 5 GB 'currency' => 'USD', 'payable_type' => $organization::class, 'payable_id' => $organization->id, 'billable_type' => $organization::class, 'billable_id' => $organization->id, 'meta' => ['product' => 'storage_addon', 'gb' => 5], ]); Billing::chargeWithMethod($payment, $organization->defaultPaymentMethod); // or Billing::charge() for a redirect checkout
Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) { if (($event->payment->meta['product'] ?? null) === 'storage_addon') { $event->payment->payable->increment('extra_storage_gb', $event->payment->meta['gb']); } });
A dedicated payable — worth it once you have several different one-off purchase types pointing at the same customer (storage add-ons, seat top-ups, ...) and want instanceof instead of string keys in meta to tell them apart. Same idea as Order in recipe #1:
class StorageAddonPurchase extends Model implements Payable { protected $fillable = ['organization_id', 'gb']; public function organization(): BelongsTo { return $this->belongsTo(Organization::class); } }
$addon = StorageAddonPurchase::create(['organization_id' => $organization->id, 'gb' => 5]); Payment::create([ // ... same fields as above, except: 'payable_type' => StorageAddonPurchase::class, 'payable_id' => $addon->id, ]);
Event::listen(PaymentSucceeded::class, function (PaymentSucceeded $event) { if ($event->payment->payable instanceof StorageAddonPurchase) { $event->payment->payable->organization->increment('extra_storage_gb', $event->payment->payable->gb); } });
Either way: sell a 10 GB or 20 GB add-on later at a different price — same listener, no new branch, since the quantity lives on meta/the payable model, not guessed from the payment amount.
4. Free trial period
No gateway call, no PaymentMethod needed — just a Subscription row:
$subscription = Subscription::create([ 'status' => SubscriptionStatus::Trialing, 'gateway' => null, // nobody knows yet how it will be paid — the first successful payment stamps its gateway here automatically 'price_id' => $price->id, 'billable_type' => $organization::class, 'billable_id' => $organization->id, 'trial_ends_at' => now()->addDays(14), ]);
TrialWillEnd fires at each trial_ending_notices interval before trial_ends_at (default ['3 days']; tune to ['7 days', '3 days', '1 day'] for a yearly plan or ['1 hour', '15 minutes'] for an hourly rental — then run billing:expire-trials more often than daily) — from the billing:expire-trials run, so it needs the schedule enabled. $event->notice tells the listener which reminder to word. A Price can also carry its own trial_ending_notices (json column): null = the global list, [] = no reminders for that price, its own array = its own cadence — so a yearly plan and an hourly rental coexist in one project. It's your hook to prompt the customer to subscribe (an email/push with a link to your payment page). If nobody converts, the same command moves trialing subscriptions past trial_ends_at to ended.
Converting is just a payment against this subscription — no separate "convert trial" method. Create a Payment with payable = $subscription and send the customer to checkout; PaymentSucceeded flips the row straight to active (the listener doesn't care it started as trialing):
// mid-trial tip: anchor the paid period on the trial's end so the remaining free days // aren't swallowed — the listener advances the period from current_period_ends_at when set $subscription->update(['current_period_ends_at' => $subscription->trial_ends_at]); Billing::charge($payment, new ChargeOptions(saveCard: true)); return redirect($payment->payment_url);
Where the saved card comes from. On every gateway the card is saved as a side effect of that first real charge (saveCard: true; only WayForPay saves it even without the flag; Stripe does it through its hosted Checkout, no frontend code) — and that's what makes every later renewal automatic. Only Stripe can additionally collect a card during the trial without charging (a SetupIntent on your frontend + attachPaymentMethod(), see "Tokenization") — but even then the conversion charge is yours to make with chargeWithMethod(): billing:expire-trials deliberately never takes money, it only closes unconverted trials.
A declined card during the trial doesn't cancel anything — dunning only applies to real renewals, the trial keeps running until it converts or expires.
5. Several independent subscriptions on the same customer at once
Subscription::$billable_id isn't unique — one Organization can have as many concurrent, independently-billed subscriptions as it needs (a base plan, an AI add-on, a per-channel add-on, ...), each with its own gateway/status/renewal cycle:
foreach (['base' => 'stripe', 'ai-addon' => 'stripe', 'channel-viber' => 'wayforpay'] as $planCode => $gateway) { Subscription::create([ 'status' => SubscriptionStatus::Active, 'gateway' => $gateway, 'price_id' => Plan::where('code', $planCode)->firstOrFail()->prices()->firstOrFail()->id, 'billable_type' => $organization::class, 'billable_id' => $organization->id, 'current_period_ends_at' => now()->addMonth(), ]); }
Cancelling or lapsing one doesn't touch the others — each row is its own independent lifecycle.
Checking what a customer can access. hasActiveSubscription($planCode)/activeSubscription($planCode) (see "Payable and Billable") already narrow to one plan — enough when access maps 1:1 to a plan code. For finer-grained, per-feature access (a plan unlocks several features, or the same feature is unlockable by more than one plan), store the feature list on the Price (or Plan, if it's the same across that plan's prices) in meta — the package never reads it, it's yours to define:
$plan->prices()->create([/* ... */, 'meta' => ['features' => ['api-access', 'export-reports']]]);
Then a small helper on your Billable model, aggregating across all currently active subscriptions (not just one — the whole point of this recipe is that a customer can hold several at once):
public function hasFeature(string $feature): bool { return $this->subscriptions() ->active() ->whereHas('price', fn ($q) => $q->whereJsonContains('meta->features', $feature)) ->exists(); }
whereJsonContains() compiles to the right dialect on its own (MySQL JSON_CONTAINS, Postgres @>, SQLite json_each) — no raw SQL to keep portable by hand.
One thing the package deliberately doesn't guard: nothing stops two active subscriptions on the same Price for the same billable. If your product treats that as an accidental duplicate rather than a valid state (unlike the base+add-on mix above), check hasActiveSubscription($planCode) in your own subscribe() action before calling Billing::charge().
6. The customer's card changed / stopped working
When a renewal charge fails, nothing special is required — that's what dunning is for: the subscription goes past_due but isActive() stays true through the grace window, SubscriptionPaymentFailed fires (your cue to email "we couldn't charge your card — update it" with a payment link), and retries run on the retry_intervals ladder (6h, 24h, 48h by default). A card reissued by the same bank sometimes starts working again on its own (network token updates), in which case a retry simply succeeds. After max_recurring_attempts the subscription is canceled.
A Price can carry its own retry_intervals (json column, same idea as trial_ending_notices): null = the global list, its own array = its own pace, [] = don't retry this one at all — the first failed renewal cancels it outright. grace_ends_at is always stamped at next_retry_at + grace_period_days, so however long the ladder gets, access never lapses between two attempts and comes back on the next failure.
Updating the card is the same move as saving the first one — a real charge with saveCard:
// a fresh Payment against the same subscription + redirect checkout $payment = Payment::create([ 'status' => PaymentStatus::Pending, 'type' => PaymentType::Charge, 'gateway' => $subscription->gateway, 'amount' => $subscription->price->amount, 'currency' => $subscription->price->currency, 'payable_type' => $subscription->getMorphClass(), 'payable_id' => $subscription->id, 'billable_type' => $subscription->billable_type, 'billable_id' => $subscription->billable_id, ]); Billing::charge($payment, new ChargeOptions(saveCard: true)); return redirect($payment->payment_url);
The customer pays with the new card → PaymentSucceeded reactivates the subscription (period advanced, dunning counters reset), and the new PaymentMethod automatically becomes the default — the previous card's is_default is demoted, so every later renewal charges the new one. Clean up the old card if you like: Billing::driver($gateway)->detachPaymentMethod($old) (Monobank and Stripe also revoke the token at the provider; the others forget it locally). On Stripe the card can also be replaced without charging at all — attachPaymentMethod() with a new pm_... becomes the default the same way.
Proactively, before it breaks: PaymentMethod::$expires_at is filled where the gateway reports card expiry (Stripe does; the Ukrainian gateways' callbacks don't), so a monthly scan of paymentMethods()->where('expires_at', '<', now()->addMonth()) works for Stripe. For the rest, the first failed renewal is the signal — and grace keeps the customer's access alive while they fix it.
7. Cut access immediately instead of granting a grace credit
"Grace keeps access alive" above is the default, but not every business wants it — a paid-content subscription might prefer to block the moment a renewal fails, while the retries (recurring_attempts, next_retry_at, eventual cancellation) keep running unchanged in the background. config('billing.grace_access') controls exactly that:
// config/billing.php — global default, both directions supported 'grace_access' => env('BILLING_GRACE_ACCESS', true), // false = cut access on the first failed renewal
Override it per Price when the policy should vary within the same app (null = the global default, either way is explicit otherwise):
$plan->prices()->create([/* ... */, 'grace_access' => false]); // this tier: no credit, cut immediately $plan->prices()->create([/* ... */, 'grace_access' => true]); // this one: keep the grace window
Only isActive() (and the matching Subscription::active() scope) reads this — recurring_attempts/grace_ends_at/the retry cadence and the eventual cancellation are identical either way. When access is cut immediately, SubscriptionAccessSuspended fires once, right at that moment (not on every subsequent retry within the same past_due episode) — your cue for a harder "access suspended, update your card to restore it" notice, distinct from SubscriptionPaymentFailed, which fires on every retry regardless of the access policy.
Either way — grace kept access on, or it was cut and the customer paid to restore it — the eventual successful retry doesn't shift the billing anchor to make up for the delay: current_period_ends_at is never touched while past_due, so the next period is computed from the originally scheduled end date, not from the day the card actually got charged. A period due January 1st that recovers on January 4th still renews February 1st, not February 4th — the days spent in past_due are effectively free, never billed for or clawed back.
8. Raising a tariff without touching current subscribers (grandfathering)
Subscription::$price_id points at one Price row, not at a Plan — that's what makes grandfathering free: never edit amount on an existing Price (it's a live FK target, not a historical snapshot; one update() reprices everyone still on it). Instead:
$newPrice = $plan->prices()->create([/* ... */, 'amount' => 15000, 'is_active' => true]); $oldPrice->update(['is_active' => false]); // hide from new signups, nothing else changes
Existing subscriptions keep their old price_id, so billing:process-recurring-charges keeps charging the old amount on every renewal — is_active is consumer-side only (like meta), just filter Price::where('is_active', true) on your pricing page/checkout. To sunset the old tariff after some grace window, bulk swapPlan() (no proration, no gateway call — the new amount applies starting the next renewal, current period runs out at the old price):
Subscription::where('price_id', $oldPrice->id)->each(fn ($s) => $s->swapPlan($newPrice));
Money
Every amount in this package — payments.amount, prices.amount, Money, receipt items — is an integer in the currency's minor units (kopiykas/cents): 10000 is 100.00. Same convention Stripe, Monobank and most PSPs use, and it keeps rounding errors out of money by construction.
Your own app storing prices as decimal(10,2) is perfectly compatible — you convert at the boundary, and Money has the conversion so you don't have to remember the trap:
use Fomvasss\Billing\Support\Money; $amount = Money::fromDecimal($product->price, 'UAH'); // '19.99' or 19.99 → 1999 (static factory) $amount->toDecimal(); // back to '19.99' for your invoice/UI (instance method, always a string) Payment::create([ 'amount' => $amount->amount, 'currency' => $amount->currency, // ... ]); // the other direction — Payment and Price hand you the pair already built: $payment->money()->toDecimal(); // '100.00' $price->money()->format(); // '1 299,00 ₴'
The trap it exists for: (int) (19.99 * 100) is 1998, not 1999 — 19.99 has no exact binary representation, so the product is 1998.9999999999998 and the cast truncates. Money::fromDecimal() rounds. Eloquent's decimal:2 cast returns a string, which sidesteps the issue on the way in — but only until something casts it to float, so route it through fromDecimal() anyway.
toDecimal() always returns exactly two decimal places ('5.00', never '5'), dot separator, no thousands grouping. For human-facing output use format() instead — it goes through Number::currency() ('1 299,00 ₴', with an optional locale argument), falling back to '1299.00 UAH' when ext-intl isn't installed, which the package doesn't require.
Parsing what a human typed
fromDecimal() trusts its input — it's the bridge from a column or an API, and (float) '1 299,00' is 1.0, silently. For an admin price field or an imported spreadsheet cell use parse(), which reads every separator convention and refuses everything else instead of guessing:
Money::parse('1299', 'UAH'); // 129900 Money::parse('1 299,00', 'UAH'); // 129900 — including the non-breaking spaces a browser pastes Money::parse('1.299,00', 'UAH'); // 129900 — uk/de grouping Money::parse('1,299.00', 'UAH'); // 129900 — en grouping Money::parse('19.999', 'UAH'); // throws: more than two decimals Money::parse('1,299', 'UAH'); // throws: 1299 or 1.299? no way to tell, so it refuses
It also never touches a float on the way in: '19.99' is composed as 19 * 100 + 99.
Arithmetic
$total = $first->plus($second); // same currency enforced — mixing throws $left = $total->minus($paid); // below zero throws: money is never negative $line = $unit->multiply($quantity); // rounds half-up to the minor unit... $line = $unit->multiply($quantity, Rounding::HalfEven); // ...unless accounting says otherwise // splitting without losing or inventing a kopiyka — 100.00 in three is 33.34 + 33.33 + 33.33 [$a, $b, $c] = (new Money(10000, 'UAH'))->allocate([1, 1, 1]); // weighted the same way: a discount spread across basket lines $shares = $discount->allocate([$line1->amount, $line2->amount]);
allocate() is the reason not to do this by hand: a naive multiply(1/3) on each share gives 33.33 three times and quietly loses a kopiyka. It floors every share and hands the leftover units to the earliest ones — order your ratios accordingly. Also equals(), isZero(), isSameCurrency().
Rounding (HalfUp — the default, HalfDown, HalfEven, Up, Down) is a parameter rather than a hidden constant because half-up and half-even are different money: over a long invoice half-up drifts in the merchant's favour, which is why some accounting rules mandate banker's rounding. Note the ceiling of all this: multiply() takes a float factor, so an inexact factor (a tax rate, proration to the day) is already approximate before rounding happens — that's the point to reach for brick/money instead of growing this class.
Known limitation: the whole package assumes two-decimal currencies (fromDecimal() multiplies by 100, toDecimal() divides by 100, and the drivers do the same on the wire). Zero-decimal (JPY) and three-decimal (BHD) currencies are not supported; every currency in the built-in gateways' supportedCurrencies() lists is two-decimal on purpose.
Gateways that want decimal major units on the wire (LiqPay, WayForPay) convert inside their own driver — never something you deal with.
What to store in your own tables
- Anything that feeds billing directly — your own tariff/plan tables, wallet balances, transaction ledgers — store as integer minor units too. Every conversion you don't have is a
fromDecimal()call nobody can forget. (billing_prices.amountalready is one — no choice there.) An admin form is not a reason to store decimals: show/accept299.00in the form, saveMoney::parse($request->input('price'), 'UAH')->amount(parse(), notfromDecimal()— form input is where'1 299,00'comes from). - Catalog prices humans edit and that don't reach billing directly (shop products, display prices) —
decimal(12,2)is fine; MySQLDECIMALis exact, not a float. Convert at the single boundary where an order total becomes aPayment. - Never
float/doublecolumns, and never float arithmetic over money in PHP — that's exactly where1998.9999...comes from. Keep sums in integer kopiykas (or bcmath for percentages), and always store thecurrencynext to the amount, even in a "UAH-only" project — until the first USD price shows up.
Model helpers
Things you'd otherwise write yourself in every consumer:
// Subscription $subscription->isActive(); // entitled to the service right now — a running trial, active, // past_due but still inside the dunning grace window, or a pause // whose resume time has come. NOT the same as status === Active $subscription->onTrial(); // trialing and trial_ends_at hasn't passed $subscription->onGracePeriod();// a renewal failed but retries are still running $subscription->isCanceled(); $subscription->isCancelling(); // cancel() was called for period end — still running until then $subscription->hasGraceAccess();// whether past_due keeps access on for this price $subscription->isProviderManaged(); // the gateway owns this subscription's lifecycle $subscription->nextPeriodEnd(); // where the period would move on a successful renewal Subscription::active()->get(); // the same predicate in SQL — kept identical to // isActive() by a parity test Subscription::forBillable($organization)->get();
// Payment $payment->isPaid(); $payment->isPending(); $payment->isFailed(); $payment->isRefund(); // this row is a refund (type=refund), not a charge $payment->refundedAmount(); // total refunded against this charge, minor units $payment->netAmount(); // amount minus the gateway's fee — null while fee is unknown $payment->hasActivePaymentUrl(); // checkout link still usable — no need to charge() again $payment->refundableRemainder(); // amount minus what's already been refunded $payment->money(); // amount + currency as a Money (see "Money" above); the same // pairing exists for the helpers above — feeMoney(), netMoney(), // refundedMoney(), refundableRemainderMoney(), and Price::money() $payment->refunds; // the child refund rows $payment->parentPayment; // set on a refund row: the charge it belongs to Payment::findByNumber('PAY-2026-000123'); Payment::paid()->get(); Payment::pending()->get(); Payment::forBillable($organization)->latest()->get();
isActive() is the one to reach for in a gate/middleware, and it answers a different question than status === Active: entitlement is derived from the row's own dates, never from how recently a scheduled command ran. A trial that lapsed at 15:30, a cancellation the customer scheduled for 15:30, a pause due to resume at 15:30 — all three change the answer at 15:30, whether or not billing:expire-trials/billing:expire-pauses have run since. Those commands only write the status down and fire the events; turn the schedule off entirely and access control stays correct. The one boundary deliberately left soft is the end of a paid period: the renewal charge goes out within the minute and resolves through a webhook, so cutting access at current_period_ends_at would blink every customer offline on every renewal — that boundary belongs to dunning.
By default it keeps access on during the dunning grace window, so a customer isn't locked out mid-retry over a card that failed once; config('billing.grace_access') (or a per-Price override) can flip that to cut access on the first failed renewal instead — see Recipes "Cut access immediately instead of granting a grace credit".
Enums
Every status/type column is backed by a string enum in Fomvasss\Billing\Enums, cast on the model:
| Enum | Column | Cases |
|---|---|---|
PaymentStatus |
payments.status |
pending, paid, failed, canceled |
PaymentType |
payments.type |
charge, refund |
SubscriptionStatus |
subscriptions.status |
trialing, active, paused, past_due, canceled, ended |
PricingType |
prices.pricing_type |
flat, licensed, metered |
Interval |
prices.interval |
minute, hour, day, week, month, year (nullable — null = one-off/lifetime price, no cycle) |
The examples in this README use the enum cases, and so should real code: typos become errors instead of silently-wrong rows, and comparisons read better. (The casts also accept the plain string values, e.g. 'status' => 'pending' — useful for seeders/fixtures.)
use Fomvasss\Billing\Enums\{PaymentStatus, PaymentType, Interval, SubscriptionStatus}; Payment::create(['status' => PaymentStatus::Pending, 'type' => PaymentType::Charge, ...]); $plan->prices()->create(['interval' => Interval::Month, ...]); if ($subscription->status === SubscriptionStatus::PastDue) { ... } // reading a cast column gives the enum instance
Each enum also has label() for UI ('Past due') and the usual cases() for building selects. Interval::Minute/Hour work for real short-cycle billing too (hourly parking/equipment rental, not just testing) — the every-minute default schedule covers them out of the box; just rethink the dunning defaults, since a 6h/24h/48h retry ladder (retry_intervals) and a 3-day grace (grace_period_days) make no sense against a one-hour period (e.g. retry_intervals => ['5 minutes'], BILLING_MAX_RECURRING_ATTEMPTS=1).
Currency conversion
Which currencies a gateway accepts:
Billing::supportedCurrencies('stripe'); // ['AED', ..., 'UAH', 'USD', ...] Billing::gateways()['stripe']['currencies']; // the same list in the settings-UI payload
The driver's built-in list is an approximation — no gateway exposes a "list my currencies" API, and actual availability depends on your merchant account's country and settings. Override it per gateway in config, without touching the driver — narrow it to what your account really has enabled, or extend it when the driver's list lags:
// config/billing.php 'gateways' => [ 'stripe' => [ // ...credentials... 'currencies' => ['UAH', 'USD', 'EUR'], // replaces the driver's default list entirely ], ],
The override feeds everything that consults the list: supportedCurrencies(), the gateways() payload, and resolveChargeAmount() below.
If a Price's currency isn't accepted by the chosen gateway, BillingManager::resolveChargeAmount() tries, in order: (1) the price's own currency, if accepted; (2) a sibling Price of the same Plan in an accepted currency — one pinned to this gateway first, a generic one (gateway = null) as fallback. "Sibling" means the same offer priced in another currency, so it has to be active and match on interval, interval_count and pricing_type; a plan priced monthly in UAH and yearly in USD has no sibling in this sense; (3) a bound CurrencyConverterContract; (4) throws BillingException. Bind a converter (e.g. an adapter over fomvasss/laravel-currency, not a hard dependency of this package):
$this->app->bind(\Fomvasss\Billing\Contracts\CurrencyConverterContract::class, MyCurrencyConverter::class);
Price the site in USD, charge in UAH
A common Ukrainian setup: prices are shown in USD, but the charge must go through in UAH (fiscalization requires the settlement currency). The rule the whole package is built around: a Payment lives in one currency — the one the money actually moves in. Convert before creating the row and record the conversion facts next to it; the USD price on the site is presentation, not billing:
use Fomvasss\Billing\Contracts\CurrencyConverterContract; use Fomvasss\Billing\Support\Money; $usd = new Money($order->total, 'USD'); // what the customer saw $uah = app(CurrencyConverterContract::class)->convert($usd, 'UAH'); $payment = Payment::create([ 'status' => PaymentStatus::Pending, 'type' => PaymentType::Charge, 'gateway' => 'monobank', 'amount' => $uah->amount, // the charge happens in UAH 'currency' => 'UAH', 'converted_from_currency' => 'USD', 'exchange_rate' => $uah->amount / $usd->amount, 'exchange_rate_at' => now(), // ...payable/billable... ]); Billing::charge($payment);
Everything downstream stays consistent for free: the webhook's amount check verifies the UAH sum, the gateway's fee arrives in UAH next to it (see "Gateway fee and net amount"), and the original USD price plus the exact rate used are on the row for any later report. For subscriptions this whole dance is automatic — resolveChargeAmount() above does the same thing and stamps the same three columns.
Configuration reference
Every setting is a config key first; the env vars below are what config/billing.php reads by default. Publish the config (--tag=billing-config) to change anything the env doesn't cover.
| Env | Default | What it does |
|---|---|---|
BILLING_SCHEDULE_ENABLED |
false |
Master switch for the package's scheduled commands. Off by default — nothing is charged, reconciled or expired until you turn it on. |
BILLING_QUEUE_CONNECTION |
app default | Queue connection for ProcessWebhookJob. |
BILLING_QUEUE |
app default | Queue name for it — give webhooks their own so a busy default queue can't delay marking payments paid. |
BILLING_MAX_RECURRING_ATTEMPTS |
4 |
Charge attempts a renewal gets in total — the first one plus the retries — before the subscription is cancelled. |
billing.retry_intervals (no env) |
['6 hours', '24 hours', '48 hours'] |
How long to wait after each failed renewal; the last entry repeats if the list is shorter than the attempts, [] means no retries at all. Per-Price override available. |
billing.period_ending_notices (no env) |
[] |
When SubscriptionPeriodEnding fires before a paid period ends — same entry shape as the trial notices. Empty means no advance notices at all. Per-Price override available. |
BILLING_GRACE_PERIOD_DAYS |
3 |
How long past_due keeps onGracePeriod() true past the next retry. |
BILLING_GRACE_ACCESS |
true |
Whether isActive() stays true through that window, or access is cut on the first failed renewal. Per-Price override available. |
BILLING_RENEWAL_RECEIPT_ITEMS |
false |
Whether a scheduled renewal carries a generic one-line fiscal basket (the plan name, the payment's full amount). Bind RenewalChargeOptionsContract for a real one — see "Fiscalizing a renewal". |
BILLING_RECONCILE_AFTER_MINUTES |
60 |
How old a pending payment must be before reconciliation polls the gateway for it. |
BILLING_WEBHOOK_PATH |
billing/webhooks/{gateway} |
Webhook route path. {gateway} must stay somewhere in it. |
BILLING_WEBHOOK_PRUNE_AFTER_DAYS |
30 |
How long stored webhook calls are kept. Lowering it below a gateway's retry horizon lets an old re-delivery fire its events again — 30 days is beyond all five (WayForPay's four days is the longest). |
BILLING_RETURN_URL_SUCCESS |
— | Where the customer lands after a successful checkout. |
BILLING_RETURN_URL_FAILED |
— | ...and after a failed one. |
BILLING_DEBUG |
false |
Verbose driver logging. Never on in production — payloads carry card data. |
Per gateway, all optional until you use that gateway:
| Env | Gateway |
|---|---|
MONOBANK_TOKEN, MONOBANK_LINK_TTL_MINUTES (60) |
Monobank |
LIQPAY_PUBLIC_KEY, LIQPAY_PRIVATE_KEY, LIQPAY_LINK_TTL_MINUTES (60) |
LiqPay |
WAYFORPAY_MERCHANT_ACCOUNT, WAYFORPAY_MERCHANT_DOMAIN, WAYFORPAY_SECRET_KEY, WAYFORPAY_LINK_TTL_MINUTES (1440) |
WayForPay |
HUTKO_MERCHANT_ID, HUTKO_SECRET_KEY, HUTKO_LINK_TTL_MINUTES (1440) |
Hutko |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
Stripe (link TTL comes from the Checkout Session's own expires_at) |
Routes
| Name | Method | Auth | Purpose |
|---|---|---|---|
billing.webhook |
POST | signature | The one webhook endpoint for every gateway ({gateway} segment). Path and middleware configurable. |
billing.return |
GET + POST | public, no CSRF | Where gateways send the browser back; fires CheckoutReturned, 303s to your return_urls.*. POST because WayForPay/Hutko return the customer that way. |
billing.pay |
GET | public | The permanent pay link for emails/invoices (see "Permanent payment link"). |
billing.checkout-form |
GET | public | Renders a form-only gateway's cached checkout as an auto-submit page, so payment_url is always a plain link. LiqPay only. |
billing.fake.show |
GET | local/testing only | The fake gateway's two-button checkout. |
Testing
Use the fake gateway (see Quickstart) in your own app's feature tests — it runs the exact same pipeline a real gateway would, so there's nothing package-specific to mock.
Poking webhooks by hand
Manual testing — replaying gateway callbacks from Postman/curl (with per-gateway signature recipes) and receiving real webhooks locally through an ngrok tunnel — has its own guide: docs/webhook-testing.md.
License
MIT