Billing is handled through Laravel Cashier with Stripe. A plan boils down to a slug, its prices, and its feature limits. That's enough to cover free, paid, and trial access without custom subscription states. Cashier handles the Stripe lifecycle, the plan handles everything else.
SUBSCRIPTION_MODE controls the overall billing posture. The value resolves to a SubscriptionMode enum in config/subscription.php.
subscribed middleware always passes. This is the default.SUBSCRIPTION_TRIAL_DAYS, default 14), then must subscribe. The middleware checks for an active trial or subscription.The mode is enforced by EnsureSubscribed middleware on gated resource routes (bookmarks, categories, tags). Subscription management routes themselves are not gated, so users can always view or change their subscription.
Plans live in the database and are seeded on first migrate. The default seed creates two: Free with usage limits, and Pro with everything unlimited.
Each plan defines its feature limits, either a number or unlimited:
Free: { "bookmarks": 10, "categories": 3, "tags": 10 }
Pro: { "bookmarks": null, "categories": null, "tags": null }
A plan can offer more than one billing interval, monthly and yearly, each with its own price. Prices are kept in sync with Stripe rather than hardcoded, so the amounts live locally and can be shown without calling Stripe on every request. Adding an interval is just another price on the plan.
Every user always has a plan. New users fall back to Free until they subscribe, so limits always apply.
Limits are checked in the app's own store requests. The authorize() method calls $this->user()->canUsePlanFeature(PlanFeature::Bookmarks), which counts the user's existing records against their plan's limit. If they're at the limit, the request aborts with a 403.
Null values in the features JSON mean unlimited. The check short-circuits and returns true without counting.
The PlanFeature enum currently supports countable features only (things backed by a relationship). The isCountable() method exists to support boolean features later without changing the checking logic.
The write side lives in SubscriptionService, which is the only thing that talks to Stripe. The ManagesSubscription trait on User holds the read side, the cached plan, the trial clock, and the is_subscribed / is_on_trial style attributes that responses serialize.
trial and the user hasn't subscribed before, trial days are applied.All operations are available at both /subscription (self-service) and /admin/users/{user}/subscription (admin managing a user).
Subscribing is its own call, POST /subscription/intent, and it exists to hand back a secret. The subscription is created at that moment in an incomplete state, so nothing is charged and nothing is granted until the client pays against the secret.
Two kinds come back and the response says which. A normal signup returns a payment secret. A signup that starts on a trial has nothing to charge yet, so it returns a setup secret instead, and the client confirms a setup rather than a payment. Card authentication, 3D Secure included, is handled inside that confirmation rather than being a separate branch the API has to describe.
The call is safe to make more than once for the same attempt. A refresh, a back button, or a second try after wandering off all return the secret already in flight rather than stacking up subscriptions. Picking a different plan mid-flow tears the old attempt down and builds a new one, since the provider won't let an unpaid subscription be swapped.
Once the payment confirms, Stripe's webhook flips the subscription to active. The client refreshes its profile to see it, polling briefly if it hasn't landed yet. That webhook is the only thing that grants the plan, and there's more on how it works and what happens if it doesn't arrive in Webhooks.
Swap is the exception to all this. It acts on a subscription that already exists and already has a card, so it still answers with the subscription itself and only carries a client_secret when the bank wants a challenge on the proration charge. An admin can't complete another user's challenge.
Off by default, behind SUBSCRIPTION_AUTOMATIC_TAX, because it has to be switched on in the Stripe dashboard too and a subscription is rejected if only one side has it.
Turning it on makes a billing address a precondition of subscribing. Tax is calculated from where the customer is, nothing in the payment flow collects an address, and Stripe refuses to create a subscription it can't resolve a location for. So the client saves an address first, at PUT /billing/address, and the intent call refuses with address_required if that hasn't happened. Country and postal code are the only two fields required, since everything else is either derivable or purely cosmetic on the invoice.
The address endpoint is also what writes the customer to Stripe, and it does one thing that looks odd until you hit it. Changing a country or postal code cancels any payment attempt still in flight. Stripe finalizes the first invoice the instant the subscription is created and never recalculates its tax, so an attempt started under the old address would keep charging the old jurisdiction no matter how many times the page reloads. Killing it forces the next attempt to build a fresh invoice. Address changes that can't move a tax location, a corrected street name say, leave the attempt alone.
With automatic tax off, none of this applies and there is no reason to collect an address at all.
Plans are a full CRUD resource at /admin/plans, protected by the plans.manage permission and PlanPolicy. Super users bypass policy checks except for delete, which always requires that no users are on the plan.
All values in config/subscription.php, all env-driven:
mode - freemium / trial / requiredtrial_days - days of trial when mode is trial (default 14)require_card_upfront - collect payment method at registration even for free/trial (default false)automatic_tax - hand tax calculation to the provider (default false)