Skip to content

Billing

Reshape's billing is built on Laravel Cashier for Stripe. The interesting design decision is that billing is polymorphic: either a User or a Company can hold a subscription, so you can bill individuals, workspaces, or both, without maintaining two separate billing systems.

The billing settings page

The billable entity

Stripe customer state doesn't live directly on User or Company — it lives on a dedicated Account model (app/Models/Account.php), attached polymorphically and using Cashier's Billable trait:

php
// The account's latest subscription, regardless of status...
$account->latestSubscription : App\Models\Subscription|null

Both User and Company expose account() (a MorphOne). Whenever the account is updated, its Stripe customer details are synced on a queued listener — so editing billing details never blocks a request.

Plans

Plans and prices are modeled in your own database (app/Models/Plan.php), then mapped to Stripe prices — this is what lets you show a pricing page, gate visibility, and validate quantities without a Stripe API round trip for every page load:

php
// The plan's prices, ordered by sort/price...
$plan->prices() : Illuminate\Database\Eloquent\Relations\HasMany

// Who this plan is targeted at (PlanAssignment)...
$plan->assignments() : Illuminate\Database\Eloquent\Relations\HasMany

// Addon plans purchasable on top of this one, and vice versa...
$plan->addons() : Illuminate\Database\Eloquent\Relations\BelongsToMany
$plan->parents() : Illuminate\Database\Eloquent\Relations\BelongsToMany

// Whether this plan should be shown to a given user or company...
$plan->isVisibleTo(Model $entity) : bool

A plan can be a regular subscription plan or an addon (PlanType::Addon) purchased on top of an existing subscription. PlanAssignment lets you target specific plans at specific accounts rather than showing every plan to everyone — isVisibleTo() is the check both the UI and checkout use.

Polymorphic billable resolution

Every billing controller is resolved for either a User or a Company billable through a custom route binding (app/Routing/BillingRouteBindings.php) — the same controllers serve both, so there's no duplication between billing a user and billing a company.

Checkout and addons

App\Actions\Billing\CreateCheckoutSession is the action behind checkout, and it makes a decision most billing implementations get wrong at first: if the account already has an active subscription, purchasing a regular plan is rejected (you swap instead), but purchasing an addon attaches it as a subscription item on the existing subscription via Cashier's addPrice(), with quantity validated against the plan's configured min/max — no new checkout session needed.

Actions (app/Actions/Billing/)

php
// New subscription -> Stripe Checkout; existing subscription + addon -> attach directly...
CreateCheckoutSession::create(User|Company $billable, PlanPrice $price, mixed $quantity, string $successUrl, string $cancelUrl) : Symfony\Component\HttpFoundation\Response

// Change the subscription's plan (and re-attach any compatible addons)...
SwapSubscriptionPlan::swap(Subscription $subscription, string $stripePriceId) : App\Models\Subscription

// Cancel a subscription at the end of the billing period...
CancelSubscription::cancel(Subscription $subscription) : App\Models\Subscription

// Resume a subscription that's still within its grace period...
ResumeSubscription::resume(Subscription $subscription) : App\Models\Subscription

// Change an addon subscription item's quantity...
UpdateSubscriptionItem::update(Subscription $subscription, string $stripePriceId, int $quantity) : App\Models\Subscription

// Remove an addon subscription item entirely...
RemoveSubscriptionItem::remove(Subscription $subscription, string $stripePriceId) : App\Models\Subscription

// A signed URL to Stripe's hosted billing portal...
CreateBillingSession::create(User|Company $billable, string $returnUrl) : string

Cashier

Since Account uses Cashier's Billable trait directly, the full Cashier API is available on $account (and therefore on $user->account / $company->account) — not just the wrapper actions above. The methods you'll reach for most:

php
// Start a new subscription...
$account->newSubscription(string $type, string|array $prices = []) : Laravel\Cashier\SubscriptionBuilder

// Check subscription state...
$account->subscribed(string $type = 'default') : bool
$account->subscription(string $type = 'default') : Laravel\Cashier\Subscription|null
$account->onTrial(string $type = 'default') : bool
$account->hasIncompletePayment(string $type = 'default') : bool

// Invoices...
$account->invoices(bool $includePending = false) : Illuminate\Support\Collection
$account->upcomingInvoice() : Laravel\Cashier\Invoice|null
$account->downloadInvoice(string $id) : Symfony\Component\HttpFoundation\Response

// Payment methods...
$account->hasDefaultPaymentMethod() : bool
$account->paymentMethods() : Illuminate\Support\Collection
$account->defaultPaymentMethod() : Laravel\Cashier\PaymentMethod|null

// Stripe's hosted billing portal...
$account->billingPortalUrl(?string $returnUrl = null) : string

See the Laravel Cashier documentation for the complete API — subscription trials, usage-based billing, tax handling, webhooks, and more.

Authorization

Every billing action authorizes against the account via a billing policy ability. See Authorization for how policies combine scoped and global permission checks.

Configuring Stripe

Stripe keys aren't included in .env.example — add them yourself once you're ready to work on billing:

ini
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Cashier configuration (currency, tax behaviour, webhook tolerance) lives in config/cashier.php.