Plans & Subscriptions

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.

This doc covers what a plan is, how access gets gated, and what happens to a subscription once it exists. Signing up has its own doc in Subscribing, along with the layers that sit on top of it, Trials, Automatic Tax, and Promotion Codes.

Subscription Modes

SUBSCRIPTION_MODE controls the overall billing posture. The value resolves to a SubscriptionMode enum in config/subscription.php.

freemium lets everyone in. Users start on the free plan and can upgrade whenever, and the subscribed middleware always passes. This is the default.

trial gives users a trial period (SUBSCRIPTION_TRIAL_DAYS, default 14), then they must subscribe. The middleware checks for an active trial or subscription. By default the trial clock is stamped at registration with no card involved. Set SUBSCRIPTION_REQUIRE_CARD=true and nothing is stamped at registration, so the user is gated from the first request until they subscribe. Which plan the trial applies to, and how it's handed to Stripe, is covered in Trials.

required has no free access. The middleware blocks anyone without an active subscription, and a user without one has no plan at all rather than falling back to free.

The mode is enforced by the EnsureSubscribed middleware on the gated resource routes (bookmarks and tags). Subscription management routes themselves are not gated, so users can always view or change their subscription.

Plans

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, "tags": 10 }
Pro:  { "bookmarks": null, "tags": null }

A plan can offer more than one billing interval, monthly and yearly, each with its own price. Prices are matched to Stripe by lookup key and kept in sync 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.

Feature Limits

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.

Subscription Lifecycle

Every write against Stripe is its own small service under App\Services\Stripe, behind a contract, so a controller never touches Stripe directly. 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.

Subscribing runs through a Stripe Checkout Session the client confirms itself, and has its own doc in Subscribing.

Swapping moves the existing subscription to a different plan or interval and invoices the proration straight away. It's refused on a trial, on a past due or unpaid subscription, and on one with a cancellation pending, since each of those needs something sorted out first. What comes back is the outcome of the proration charge, whether it was paid, needs a bank challenge, or failed. The price change stands either way, so an error would read as though nothing happened.

Cancelling cancels at period end. The user stays active through the grace period, and nothing is charged or refunded.

Resuming clears a pending cancellation before the period ends. It refuses when there's no card to renew against, because Stripe would happily accept the resume and the failure would only land weeks later at the renewal, dropping the user into dunning. Once the paid term has run out there's nothing left at Stripe to resume, so it answers the same as having no subscription and the user subscribes again.

Cancel and resume are both safe to repeat. Cancelling something already cancelled, or resuming something that's running, answers success with the current state, so a double submit or a second tab never turns into an error.

Admins can view, cancel, and resume a user's subscription at /admin/users/{user}/subscription. They can't create or swap one on the user's behalf, since both need the user's own card and, for a bank challenge, the user's own browser.

Admin Plan Management

Plans are listed, shown, and updated at /admin/plans, protected by the plans.manage permission and PlanPolicy. Super users bypass the policy. There's no create or delete since plans are seeded.

A price's lookup key can be changed at /admin/plans/{plan}/prices/{price}, which syncs the amount from Stripe on the spot. A key that doesn't match an active price, or matches one that bills on a different interval than the price row claims, is rejected rather than synced.

© Websanova 2026 About Privacy