Stripe billing
Purchases::stripe() creates and changes things at Stripe over its REST API with Laravel’s HTTP client — no Stripe SDK. Inbound webhooks keep the outbound state in sync locally. Only Stripe can bill; Apple and Google sell in their own storefronts. Billing needs PURCHASES_STRIPE_SECRET, and every call uses the pinned API version:
use RoundlyConsulting\Purchases\Facades\Purchases;
Purchases::canBill('stripe'); // true — the provider implements Contracts\SupportsBilling
Purchases::canBill('apple'); // false
Purchases::stripe(); // StripeBilling; UnsupportedCapabilityException without a registered Stripe provider
// Dependency injection — the same object behind the facade
public function __construct(private PurchasesManager $purchases) {}
$this->purchases->stripe()->subscriptions()->updateQuantity($seatItem, 11);| Sub-accessor | Method | Action |
|---|---|---|
customers() | sync($owner, CustomerData $data) / id($owner) / owner($customerId) | SyncStripeCustomerAction |
subscriptions() | create($owner, CreateSubscriptionData $data) | CreateStripeSubscriptionAction |
subscriptions() | checkout($owner, CheckoutSubscriptionData $data) | CreateStripeCheckoutSessionAction |
subscriptions() | updateQuantity() / swap() / addItem() / removeItem() / change() | ChangeStripeSubscriptionAction |
subscriptions() | cancel() / cancelAtPeriodEnd() | CancelStripeSubscriptionAction |
subscriptions() | resume() | ResumeStripeSubscriptionAction |
subscriptions() | preview($subscription, ?SubscriptionChange $change) | PreviewStripeInvoiceAction |
meters() | create() / find() / findOrCreate() | CreateStripeMeterAction / FindStripeMeterAction |
meters() | report() / queue() | ReportStripeMeterEventAction / ReportStripeMeterEvent job |
| — | portal($owner, ?$returnUrl, ?$configuration) | CreateStripePortalSessionAction |
A subscription is a stored Stripe Subscription or its id (sub_…), an item a stored SubscriptionItem or its id (si_…); a stored subscription of another provider is refused with BillingException before anything is sent. Every write sends an Idempotency-Key — yours or a fresh UUID — which every StripeException carries, so a failed call can be retried with the same key.
Customers — one per owner
use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\{AddressData, CustomerData, TaxIdData};
$customer = Purchases::stripe()->customers()->sync($company, new CustomerData(
name: 'Acme s.r.o.',
email: '[email protected]',
address: new AddressData(line1: 'Hlavná 1', city: 'Bratislava', postalCode: '81101', country: 'SK'),
taxIds: [new TaxIdData('eu_vat', 'SK2020123456')],
metadata: ['plan' => 'team'],
preferredLocale: 'sk',
));
Purchases::stripe()->customers()->id($company); // 'cus_…' or null
Purchases::stripe()->customers()->owner('cus_…'); // the owner model or nullThe owner is any saved Eloquent model. Without a customer, sync() creates one and links it; with one, it updates only the fields you set, and a taxIds list becomes exactly that list. The link lives in billing_customers through the BillingCustomers contract — bind your own implementation if you already store the id. When a subscription or payment that has no owner yet is recorded, it is linked to the owner of its customer.
Subscriptions
use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\{CreateSubscriptionData, SubscriptionPrice};
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\{CollectionMethod, PaymentBehavior};
$subscription = Purchases::stripe()->subscriptions()->create($company, new CreateSubscriptionData(
prices: [
SubscriptionPrice::licensed('price_seats', 10), // per seat, quantity 10
SubscriptionPrice::metered('price_storage_gb'), // usage-based: no quantity
],
name: 'team', // the local row's name, what subscribedTo() checks
trialDays: 7,
automaticTax: true,
collectionMethod: CollectionMethod::ChargeAutomatically, // or SendInvoice + daysUntilDue
paymentBehavior: PaymentBehavior::DefaultIncomplete,
metadata: ['company' => (string) $company->getKey()],
), idempotencyKey: "start-{$company->getKey()}");The answer is recorded locally right away through the webhook pipeline, owned by the owner and named name; SubscriptionStarted fires for an active or trialing subscription. Options you leave null are not sent, so Stripe’s defaults apply.
Flexible billing mode and mixed intervals
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\BillingMode;
Purchases::stripe()->subscriptions()->create($company, new CreateSubscriptionData(
prices: [
SubscriptionPrice::licensed('price_licence_yearly'), // billed every year
SubscriptionPrice::metered('price_api_calls_monthly'), // usage, billed every month
],
name: 'platform',
billingMode: BillingMode::Flexible, // billing_mode[type]=flexible
));- billingMode sends billing_mode[type]; unset, the purchases.settings.stripe.billing_mode default, else nothing — Stripe’s default is flexible from API version 2025-09-30.clover, so already on the pinned 2026-05-27.dahlia. A billing mode needs 2025-06-30.basil or later: on an older pinned api_version the call throws BillingException before Stripe is asked. A subscription moves from classic to flexible, never back.
- Mixed intervals need flexible mode, and each item’s interval must be a multiple of the shortest (1 month + 1 year; not week + month). Stripe bills each item on its own period.
- Locally, ends_at is the latest item period end — the annual licence’s paid-through date; each item’s own period is in meta.
- Checkout cannot create a mixed-interval subscription (use create()), and cancelAtPeriodEnd() cancels at the earliest item period end.
Trials without a payment method
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\MissingPaymentMethod;
Purchases::stripe()->subscriptions()->create($company, new CreateSubscriptionData(
prices: [SubscriptionPrice::licensed('price_team')],
trialDays: 14,
trialEndBehavior: MissingPaymentMethod::Pause, // trial_settings[end_behavior][missing_payment_method]
));A trial needs no payment method. What happens when it ends without one is trialEndBehavior — else purchases.settings.stripe.missing_payment_method, else Stripe’s create_invoice. A trialEndBehavior without a trial is a BillingException:
| MissingPaymentMethod | At Stripe | Locally |
|---|---|---|
Cancel | cancels it at once (customer.subscription.deleted) | Canceled, SubscriptionCanceled |
Pause | pauses it; no invoices until it resumes (customer.subscription.paused) | OnHold, SubscriptionOnHold; back to Completed with SubscriptionResumed + SubscriptionRenewed when resumed |
CreateInvoice | invoices anyway (Stripe’s default); unpaid, it goes past_due | InGracePeriod (or OnHold with settings.stripe.past_due = on_hold) |
Three days before the trial ends Stripe sends customer.subscription.trial_will_end, which fires SubscriptionTrialWillEnd — the moment to send the customer to the Customer Portal to add a payment method. A paused subscription resumes from the Portal or the hosted invoice page; the package has no call for Stripe’s /resume endpoint.
One subscription per owner
// config/purchases.php — or PURCHASES_ONE_SUBSCRIPTION_PER_OWNER=true
'subscriptions' => [
'one_per_owner' => true,
'lock' => ['store' => 'redis', 'wait' => 10], // a store with atomic locks, shared by every server
],
Purchases::for($company)->hasOngoingSubscription(); // what the limit checks
Subscription::query()->ongoing()->get(); // every status but canceled, failed and refunded- With purchases.subscriptions.one_per_owner on, create() and checkout() throw BillingException and send nothing while the owner holds a subscription that is not over — any status but canceled, failed (expired) and refunded, of any provider. A subscription canceled at period end still counts until it ends.
- The check, the Stripe call and the local recording run under one atomic cache lock per owner, so of two concurrent starts the second waits up to lock.wait seconds, sees the first and is refused — or, if the lock stays taken, throws BillingException (“Another subscription is being started”).
- Checkout only creates the subscription once the customer pays, so two sessions opened before either is paid can still both start one — turn on Stripe’s Checkout setting “Limit customers to one subscription” too. Subscriptions started outside the package are never blocked, and webhooks always record what Stripe reports.
Checkout in subscription mode
use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\CheckoutSubscriptionData;
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\PaymentMethodCollection;
$session = Purchases::stripe()->subscriptions()->checkout($company, new CheckoutSubscriptionData(
prices: [SubscriptionPrice::licensed('price_seats', 10), SubscriptionPrice::metered('price_storage_gb')],
successUrl: route('billing.done').'?session={CHECKOUT_SESSION_ID}',
cancelUrl: route('billing'),
trialDays: 7,
paymentMethodCollection: PaymentMethodCollection::IfRequired, // a free trial without a card
trialEndBehavior: MissingPaymentMethod::Cancel,
automaticTax: true, // also sends customer_update[address]=auto, which Stripe Tax needs
));
return redirect($session->url);The subscription Checkout creates arrives by webhook and is linked to the owner through its customer. PaymentMethodCollection::IfRequired asks for a payment method only when something is due now, so a free trial starts without one; trialEndBehavior and billingMode go into subscription_data and fall back to the same config defaults as create().
Changes, cancellation, meters and the Portal
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\ProrationBehavior;
$seats = $subscription->itemFor('price_seats'); // the stored item for a price
Purchases::stripe()->subscriptions()->updateQuantity($seats, $company->members()->count(), ProrationBehavior::CreateProrations);
Purchases::stripe()->subscriptions()->swap($seats, 'price_seats_annual', quantity: 11, proration: ProrationBehavior::AlwaysInvoice);
Purchases::stripe()->subscriptions()->addItem($subscription, 'price_api_calls'); // metered: no quantity
Purchases::stripe()->subscriptions()->removeItem('si_…', ProrationBehavior::None, clearUsage: true);
Purchases::stripe()->subscriptions()->cancelAtPeriodEnd($subscription); // POST cancel_at_period_end=true
Purchases::stripe()->subscriptions()->resume($subscription); // POST cancel_at_period_end=false
Purchases::stripe()->subscriptions()->cancel($subscription, invoiceNow: true, prorate: true); // DELETE, nowEach change is one POST /v1/subscriptions/{id}, recorded locally, and SubscriptionUpdated fires for a new quantity or price. ProrationBehavior is CreateProrations (default), AlwaysInvoice or None; several changes go in one change() with a SubscriptionChange, and preview() shows the invoice before you confirm. resume() takes back a cancellation scheduled for the period end — it is not Stripe’s /resume endpoint.
use RoundlyConsulting\Purchases\DataTransferObjects\Stripe\MeterData;
use RoundlyConsulting\Purchases\Providers\Stripe\Enums\MeterAggregation;
$meter = Purchases::stripe()->meters()->findOrCreate(new MeterData(
eventName: 'storage_gb_days',
displayName: 'Storage (GB-days)',
aggregation: MeterAggregation::Sum,
));
Purchases::stripe()->meters()->report(
$company, // an owner with a Stripe customer, or 'cus_…'
'storage_gb_days',
12.5, // int, float or decimal string — sent as an exact decimal string
timestamp: now(),
identifier: "storage-{$company->id}-".today()->toDateString(),
);
$session = Purchases::stripe()->portal($company, returnUrl: route('settings.billing'));
return redirect($session->url);Usage is reported to Stripe Billing Meters as an exact decimal string; the identifier (generated when you give none) doubles as the idempotency key, and queue() fixes the customer, identifier and timestamp when queued. A Portal URL is short-lived, so create one per visit.
Errors
Every Stripe failure is a typed StripeException — card, invalid request, authentication, rate limit, idempotency, API or connection — carrying httpStatus, stripeType, stripeCode, declineCode, param, requestId and idempotencyKey. Its message is Stripe’s own with anything shaped like a secret key replaced by [redacted], and no exception keeps the HTTP response. Problems found before anything is sent throw BillingException; see Enums & exceptions.
Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.