Skip to main content

The account hub

When billing.enabled is on, the hub mounts under config('account.prefix') (default account/billing) behind your web + auth middleware:

RouteScreen
/Overview + tier summary
/subscriptionState, next-invoice preview, cancel / resume, portal link
/planSubscribe or in-app swap with a proration preview, and the coupon field
/payment-methodsList, set-default, remove, add
/invoicesHistory + ownership-checked PDF download
/usageMetered dimensions
/usage/historyPast periods + add-on top-up timeline
/recoveryDunning recovery surface
/dangerImmediate cancel
/portalRedirect to the provider's hosted billing portal
/checkout/returnWhere 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. Set success_url and cancel_url: their defaults are routes of the hub, and a checkout without them is refused with the name of the missing setting. purchase_success_url and purchase_cancel_url send 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_url and portal_return_url. Without them PaymentMethods::addMethodUrl() and HostedPortal::url() return null instead of failing. The portal also takes a return URL on each call.

  • The billing.dunning middleware answers instead of redirecting. A past-due owner's browser request gets the status in billing.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-realtime is 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.enabled on 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.


← Back to the documentation index