The account hub
When billing.enabled is on, the hub mounts under config('account.prefix') (default account/billing)
behind your web + auth middleware:
| Route | Screen |
|---|---|
/ | Overview + tier summary |
/subscription | State, next-invoice preview, cancel / resume, portal link |
/plan | Subscribe or in-app swap with a proration preview, and the coupon field |
/payment-methods | List, set-default, remove, add |
/invoices | History + ownership-checked PDF download |
/usage | Metered dimensions |
/usage/history | Past periods + add-on top-up timeline |
/recovery | Dunning recovery surface |
/danger | Immediate cancel |
/portal | Redirect to the provider's hosted billing portal |
/checkout/return | Where a customer lands coming back from the provider |
See Subscriptions for the subscribe/swap flow behind /plan.
The proration preview and the swap on /plan ask the provider on your platform account, so each is limited per
owner: ten previews and five swaps a minute, far above what a customer clicking through the plans asks for. One
Livewire request can carry up to payload.max_calls calls on each of payload.max_components component entries
(50 and 200 by default), and the limit is what keeps a single request from turning into thousands of provider
calls. A call over the limit answers 429 and ends the request.
These screens manage one contract: the default type, at the platform. A contract of another type, a
sponsorship beside the subscription for example, is not listed here and cannot be canceled, resumed or swapped
from these screens. Give it a screen of your own and name the type when you call SubscriptionActions:
$actions->cancel($user, type: 'sponsorship'); // at the period end
$actions->resume($user, type: 'sponsorship');
$actions->swap($user, 'gold', type: 'sponsorship');
Under a driver this package bills itself, swap a contract of another type with prorate: false. The proration
is priced against the default contract, so a prorated swap of any other type is refused rather than credited
against the wrong one.
What Subscribe does depends on the driver, and the screen does not change. Under a driver whose provider runs the cycle it opens that provider's hosted checkout. Under a driver this package bills itself it writes an intent and sends the customer to establish a mandate through a first payment; the subscription becomes real when that mandate settles, and until then the screen shows an activating state and polls.
/checkout/return follows the same split. Under a hosted checkout it reconciles the subscription from the
provider, which is a courtesy for a customer who beats the webhook home. Under a driver this package bills
itself there is nothing at the provider to read back, so it reconciles nothing and simply hands the customer
to the subscription screen. The webhook is the durable path either way.
The coupon field sits on /plan, and the status under it means one thing: would the driver you
configured actually apply this code? Not "does the code exist" — see
Subscriptions for which catalog answers under which
driver, and why a code can be valid in your configuration and still be reported as invalid.
Canceling can take two clicks if you want it to. account.cancel_requires_confirmation arms the
cancellation on /subscription and carries it out on the second click, with a way back in between. It is off
by default and the default does not move: canceling here is reversible, and for the person doing it less
friction is the better behavior.
The danger zone asks who is clicking, and what proves it is yours to decide. Ending a subscription at once
cannot be undone, so /danger asks the signed-in user to prove they are the person behind the session, with five
tries every five minutes. An account with a password types its password. An account that signs in through a
provider or a link by mail has none, so by default it types its account email. That stops an accidental click, but
not someone who took the session over, because whoever holds a session can read the account's email. Bind
ReconfirmsIdentity to a factor your application already has, and the danger zone asks for that instead:
use Illuminate\Database\Eloquent\Model;
use Pushery\Billing\Contracts\ReconfirmsIdentity;
use Pushery\Billing\Support\PasswordOrEmailReconfirmation;
final class OneTimeCodeReconfirmation implements ReconfirmsIdentity
{
public function __construct(
private PasswordOrEmailReconfirmation $default,
private OneTimeCodes $codes, // your application's own
) {}
public function prompt(Model $actor): string
{
return $actor->getAttribute('password') === null
? __('Enter the code from your authenticator app.')
: $this->default->prompt($actor);
}
public function confirms(Model $actor, string $credential): bool
{
return $actor->getAttribute('password') === null
? $this->codes->verify($actor, $credential)
: $this->default->confirms($actor, $credential);
}
}
// In a service provider of your application:
$this->app->bind(ReconfirmsIdentity::class, OneTimeCodeReconfirmation::class);
The tries stay limited whatever you bind, and the typed value is never stored.
Add the shell banner to your layout. It renders nothing for a healthy account, and nothing at all while billing is switched off. It is a plain Blade component, so it needs neither Livewire nor the account hub:
<x-billing::banner />
The banner reads every contract the owner holds and shows the most urgent notice. At equal rank the contract of
the default type wins. A failed or unconfirmed payment links to /recovery whichever contract it is on, because
that screen reads every contract too, and under Stripe the card added there becomes the default for each
subscription that names a card of its own. A grace period, a pause or an ending trial on a contract of another type
has no link, because the subscription and plan screens act on the contract of the default type alone.
Every screen renders inside a publishable app shell (layouts/account.blade.php): a grouped sidebar
navigation with the active item marked, a skip link to the main content, a typed document title, and a
POST-logout form shown only when your app registers a logout route. It needs no UI-kit dependency; publish
billing-views to replace it with your own design system's shell.
If an external merchant of record owns billing (an app-store subscription, an external portal), set
billing.link_out (env BILLING_LINK_OUT) to that portal's URL: the plan screen then links out to it and
suppresses the in-app checkout it is not the merchant of record for. On a native runtime, set
billing.runtime=native (env BILLING_RUNTIME) to hide flows an app store forbids in-app.
Keeping your own account screens
A screen of your own reads an owner's state through Pushery\Billing\Support\OwnerSubscriptionState, the reader
every hub screen and the banner ask, so your screens cannot come to read a state differently from the package:
use Pushery\Billing\Support\OwnerSubscriptionState;
$states = app(OwnerSubscriptionState::class);
$state = $states->for($owner); // a SubscriptionState
$subscription = $states->subscriptionOf($owner); // the row it read, or null
$state = $states->presented($owner, $subscription); // the same state, without a second query
$state = $states->for($owner, pendingActivation: true); // just back from checkout
$state = $states->for($owner, $merchant); // a merchant's subscription, not the platform's
An owner without a subscription still has a state: on a generic trial, or never subscribed. A customer the
provider knows from a one-time purchase or a saved card is one who never subscribed; an owner who had a
subscription keeps its row and reads as Ended.
An application that already has billing screens of its own turns the hub off with BILLING_ACCOUNT_ENABLED=false
(account.enabled). None of the routes above are registered then, and neither are their Livewire components, so no
second billing surface sits behind the same sign-in. What changes with it:
-
A checkout returns to the URLs you set under
billing.checkout. Setsuccess_urlandcancel_url: their defaults are routes of the hub, and a checkout without them is refused with the name of the missing setting.purchase_success_urlandpurchase_cancel_urlsend an add-on purchase somewhere else than a subscription. -
The page that adds a card and the hosted portal need return URLs of their own,
payment_methods_return_urlandportal_return_url. Without themPaymentMethods::addMethodUrl()andHostedPortal::url()returnnullinstead of failing. The portal also takes a return URL on each call. -
The
billing.dunningmiddleware answers instead of redirecting. A past-due owner's browser request gets the status inbilling.dunning_status(402 by default), since there is no recovery screen to send them to. Render that status as a page of yours that leads to your own recovery screen. -
The banner keeps working. It leaves out its link when the screen it would point to is not registered.
-
The realtime bridge stays registered.
billing.account-realtimeis no screen of the hub but the headless component that refreshes the screens live and shows the toasts, so your own layout can mount it. Mount it under the condition the hub's layout uses,billing.realtime.enabledon and a runtime that is not native:@if (config('billing.realtime.enabled') && config('billing.runtime') !== 'native')<livewire:billing.account-realtime />@endif
The admin console has the same switch, BILLING_ADMIN_ENABLED=false (billing.admin.enabled), and either can be off
while the other stays on.
Hosting your own screens in the hub
The hub navigation is config-driven, and the hub hosts screens it does not own. To slot one of your own app
or auth screens — sessions, connections, set-password, an onboarding step — into the hub, register its route
in billing.navigation (an optional group places it in a labeled section,
billing::account.nav.group.<group>):
// config/billing.php
'navigation' => [
'sessions' => ['label' => 'account.sessions', 'route' => 'app.sessions', 'group' => 'account', 'order' => 60],
],
The entry appears in the navigation only once that route actually exists, so the same config can name a section your app builds later — an unregistered route (or one that needs parameters the hub cannot supply) is silently dropped, never rendered as a broken link. The package ships no ancillary-screen classes of its own; it only slots your route into the shell.