The memberships system handles Stripe-backed signup, recurring billing across multiple line items, per-page content gating, and a license-key server for verifying customers of your own software. It's off by default and adds zero overhead to sites that don't use it. The same module powers different business models: a CMS company selling its own software, a yoga studio gating premium classes behind paid memberships, a SaaS issuing API keys to subscribers, or any combination.
When to turn it on
Turn on Memberships if you want to:
- Sell recurring subscriptions for content, software, services, or any mix of the three
- Gate individual pages or posts behind paid plans (paid newsletter, member-only docs, premium tutorials)
- Issue license keys to your customers so their own software or installs can verify entitlement against your server
- Bill multiple things to one customer (a CMS subscription + hosting add-on + monthly support time, all on one Stripe invoice)
Leave it off if your site is purely marketing-driven with no concept of paying members.
How it works
Three independent pieces, all toggleable from Dashboard → Memberships → Settings:
- Content gating — Adds a Required Plan dropdown on every Page, Post, and Content Item edit screen. When a member-required visitor hits the page, the gate redirects them to login (anonymous) or upgrade (logged in but lower tier). File-based pages get the same treatment via the page editor's Advanced section.
- License keys — Issues a unique 32-character key to every paying member and exposes a public
/api/membership/checkendpoint. External software (your own iOS app, desktop software, or another WebProCMS install) sends the key as a Bearer token, gets back{active, tier, expires_at}, and decides what to unlock locally. This is how webprocms.com sells WebProCMS itself — paying customers paste their key into their install and the daily check phones home. - Feature gating — Two layers. The plan gate is always on (2026-09-09): every premium feature (an opt-in key —
Features::optInKeys()) runs on a client install only while its license check reports an active plan (Features::premiumLocked()); free features (default-on) are never locked, and the license server, author installs, local dev and the test suite are exempt. The owner's configured switches survive a lapse (configuredEnabled()), the Features page shows the cards as locked, and renewal turns everything straight back on at the next 6-hour poll. Because a fleet's client installs license with the agency's key, an agency that stops paying loses premium features on its own site and on every client site at once — AI chat, the assistant, the members area, all of it — with no notion of "whose key" needed on the install. The Require membership for features toggle (beside License keys) is the second, reseller-only layer: it locks every feature, free ones included, behind an active membership for installs you distribute. Install-specific rules beyond that live as custom features that subscribe to the membership state viaFeatures::registerEnableFilter().
The subscription model
A Member has one Stripe subscription with up to three independently-managed line items, each from a different category:
| Category | What it sells | Required? |
|---|---|---|
| Software Features | The "license unlocks something" line — the only category whose presence flips active=true on the license-check endpoint |
Optional |
| Hosting | A managed-hosting add-on, sized by traffic (Small / Medium / Large / Custom) | Optional |
| Support | Monthly support time, billed by minutes per month (e.g. Core / Pro / Elite) | Optional |
At least one line item is required for a Member record to exist. Hosting-only or Support-only customers are valid — they're paying customers but their license check correctly returns active=false because they don't have the Software Features line item. A self-hosted customer can have Software + Support without Hosting; a managed customer might have all three. Each line item is independent — adding, swapping, or cancelling one doesn't affect the others.
Categories are admin-managed, not hardcoded. The seeded defaults are Software / Hosting / Support, but a yoga studio install would rename them to Membership / Premium Library / Trainer Hours (or whatever) — the license-unlock flag follows the category, not its slug.
Plans
Each category has multiple plans (tiers within that category). Plans live at Dashboard → Memberships → Plans and carry:
- Name + slug + description for display
- Stripe price ID (paste this from Stripe Dashboard after creating the recurring price)
- Price in cents + Billing interval (monthly or annual) — must match the Stripe price's interval. Used for display labels (
/movs/yr) and for the custom-price minting flow. - Free trial (days) — when set, the register page shows an "N-day free trial" line on the plan and the embedded checkout starts the subscription with
trial_period_days. Stripe reports the subscription astrialing, which maps to an active member, so access unlocks immediately; the first charge lands when the trial ends. When a signup combines multiple plans, the longest trial among them applies (Stripe trials are subscription-level). - Team seats (incl. owner) — how many people the plan covers. 1 (the default) means no team. See "Team seats" below.
- Monthly support minutes (only meaningful for support-category plans, used by the future support-ticketing feature)
- Sort order — higher sort_order = higher tier within the same category. Used for the "or higher" rule when content is gated by plan.
Monthly vs annual billing. Each plan picks one interval. If you want to offer both — say Members $20/mo and Members $200/yr — create two plans, one for each interval, and let the customer pick on the register page. Plans default to monthly. The interval flows through to the formatted display string ($20.00/mo vs $200.00/yr) and to the custom-price minting flow, so an annual plan with admin-set custom pricing also bills annually in Stripe.
Plans without a Stripe price ID ("Custom Software", "Custom Hosting", etc.) are admin-only placeholders. When an admin assigns them to a Member, the dashboard mints a one-off Stripe price for that customer at the amount specified, billed at the plan's interval. Used for comp accounts and bespoke pricing.
Signup flow
- Guest visits
/members/registerand selects up to three plans (one per category) - They enter email + password + (optional) name and submit
- The form creates the Member record locally with
status=activeand no Stripe IDs yet - A Stripe Customer is created via
customers.create, and the Member'sstripe_customer_idis filled in - An embedded Stripe Checkout session (
ui_mode: embedded, same UI as the shop checkout) is created with oneline_itemsentry per selected plan, member ID embedded inclient_reference_idandmetadata.member_id - The guest is redirected to
/members/checkout, where Stripe's payment form renders embedded on-site (the session's client secret is carried over in the HTTP session) - On success, Stripe returns them to
/members/checkout/success?session_id=… - The success controller fetches the Checkout session (with subscription + items expanded), calls
MemberSubscriptionSync::syncFromStripeSubscription()to write the line items locally, generates a license key if license-keys mode is on, and logs the Member in - The same sync also runs from the
checkout.session.completedwebhook — idempotent onstripe_subscription_item_idso it's safe to run both
If the user abandons Checkout and returns later, they're a Member with no active line items. They can log in normally but see "no subscriptions" on their dashboard, with a "Browse plans" link back to register.
Coupons work out of the box: the embedded checkout is created with allow_promotion_codes, so any promotion code minted in Stripe Dashboard can be entered in the payment form.
Failed payments (dunning)
When a renewal charge fails, Stripe's smart retries take over the charging cadence, and the CMS layers member-facing communication on top:
- Each
invoice.payment_failedwebhook pauses the member (existing behavior), advances a dunning stage (1 → 2 → 3, capped), records Stripe's next retry time from the invoice, and emails the member an escalating notice — stage 1 is a gentle heads-up, stage 2 a firmer reminder, stage 3 a final notice that the subscription will cancel if the payment keeps failing. Every email links to the billing portal to update the card. - The member's own dashboard shows a banner ("Your last payment failed… we'll retry on {date}") with an Update payment method button; the admin's Members list shows a red Past due N/3 badge with the retry date.
- A successful payment resets the episode: stage back to 0, retry timestamp cleared, member active again.
- Emails send through the Marketing mailer and can be switched off under Memberships → Settings → Retention & failed payments.
Cancellation-save flow
Members cancel from their own dashboard ("Cancel subscription" under Your subscriptions) — and before the cancel goes through, the flow offers two alternatives:
- Pause instead — pauses Stripe billing (
pause_collection, invoices voided) for 1–N months (admin-configurable cap, default 3). Access pauses with the billing: the sync maps a paused-collection subscription to member statuspausedeven though Stripe still reports itactive. The member's dashboard shows "Paused until {date}" with a Resume now button that ends the pause early; otherwise billing resumes automatically on the chosen date and the next paid invoice reactivates access. - A discount offer — the admin configures a Stripe coupon ID + a display label (e.g. "50% off for 3 months") under Memberships → Settings → Retention & failed payments. Accepting applies the coupon to the live subscription instantly. One redemption per member, tracked on the member record.
- Cancel anyway — cancels at period end (access continues until the paid time runs out), stamping the end date on the member's line items so both dashboards show "Cancels {date}".
Every outcome — pause, resume, discount, cancel — is recorded in member_retention_events with the member's optional free-text reason, so you can see why people leave and whether the save offers work.
Team seats
A plan whose Team seats is above 1 turns its subscribers into team owners: one subscription, N people.
- The owner's member dashboard grows a Your team card showing seat usage ("2 of 5 seats used") with an invite form (email + optional name). Inviting creates the teammate's member account immediately, attached to the owner, and emails them a set-your-password link (same reset broker as forgot-password). No payment step — their access rides the owner's subscription.
- Content gating evaluates seat members against their owner: they get whatever plan access the owner's active line items grant, and lose it the moment the owner is paused or cancelled. The seat member's own status still applies too, so one seat can be suspended individually.
- Removing a teammate (owner dashboard or admin) detaches them — the account survives as a free member account that can subscribe on its own. Deleting an owner detaches all seats the same way.
- Seat members see "Your access is provided by {owner}'s team subscription" on their dashboard and have no billing controls of their own. License keys remain individual — a seat member does not inherit the owner's license-unlock entitlement.
- The allowance is the highest
seatsvalue among the owner's active plans; the admin Members list marks seat accounts with a Seat badge.
Member-facing pages
Every page below uses the public layout so it matches the site's branding:
/members/login,/members/logout,/members/register/members/forgot-password,/members/reset-password/{token}/members/dashboard— current line items, license key (with copy + regenerate), Manage Billing button/members/billing— redirects to Stripe's Customer Portal for self-service payment-method updates/members/upgrade?plan=…— the member's own plan picker, and where a member on a lower tier lands when they hit gated content
Members can change their own plan
/members/upgrade (the Plans pill) lists, for each subscription the member holds, the other active plans in that same category — plus each plan's price and, where the plan grants any, its monthly AI credit. Switching is immediate and prorated, through the same service the admin Member screen uses.
Deliberate limits, because every plan id arrives from the browser:
- Swap only, never add. A member can move the subscription they hold within its category. Taking on a new category is a purchase, and goes through checkout.
- Only plans Stripe can bill are offered — a plan with no Stripe price would fail inside the changer, so it never appears.
- Two clicks. The first arms a confirmation and explains the proration; the second acts. A plan change bills immediately, so it is never one stray click away.
- Every guard is re-checked on the acting click, not just when the list renders — the category the member holds, the plan still being active, and it not being the plan they are already on.
- Failures stay generic. A Stripe error names subscription ids and plan slugs; the member sees a message telling them to get in touch instead.
The account area (one nav across every feature)
/members/dashboard is the account home, and resources/views/partials/account-nav.blade.php is the pill nav included at the top of every account page. Each pill appears only when its feature is on, and every list matches records by the signed-in account's email — the feature's own records (clients, donors, bookers) and member accounts are separate systems, so email is the join.
| Pill | Route | Feature |
|---|---|---|
| Saved Homes / Saved Searches | real-estate.saved-* |
real_estate |
| Invoices | /members/invoices |
client_invoicing |
| Agreements | /members/agreements |
client_invoicing (proposals + contracts) |
| Files / Approvals | /members/files, /members/approvals |
client_portal |
| Projects | /members/projects |
project_tracking |
| Appointments | /members/appointments |
online_booking |
| Event Tickets | /members/event-tickets |
event_ticketing |
| Donations | /members/donations |
donations |
| Support | /support/mine |
tickets |
| Plans | /members/upgrade |
memberships |
| My Installs | /members/installs |
fleet/license server |
Each list page is owned by its own module (route in the module's route file, view in app/Features/{Name}/resources/views/public/⚡my-*.blade.php), and each links out to the detail page that already existed — the tokenized project tracker, the booking manage page, the ticket order page, the proposal/contract signing page. Those tokenized pages stay the single implementation of every detail view and every action (reschedule, cancel, sign, accept); the account area is navigation to them, not a second copy of them.
The account dashboard mirrors the same set as summary cards, each shown only when the member actually has records — projects underway, the next appointment, ticket orders on file, gifts and lifetime total, proposals and contracts.
Two consequences worth knowing:
- The account HOME is gated separately from the pills.
/members/dashboardonly exists when aMemberAccounts::FEATURESfeature is on (memberships, ecommerce, real estate, courses, client portal), while e.g./members/invoicesridesclient_invoicing. The nav filters itself withRoute::has()so an invoicing-only install renders the pills that exist instead of fataling on a missing route. - These pages are never row-editable. They're per-visitor logged-in lists, so
VoltFileService::discoverFeatureModulePages()skips them from the page editor's picker the same way it skips the member dashboard.
Members log in via the members auth guard (separate from the CMS admin web guard). An admin user logged in as a CMS Admin is not automatically a Member — they're separate identities.
Content gating
When content gating mode is on, the Content Item edit screens grow a Required Plan dropdown that lists every active plan grouped by category. Selecting a plan on a piece of content gates it: anonymous visitors get redirected to login, members on lower plans get redirected to upgrade, members on the required plan (or higher in the same category) get through.
For file-based pages (the pages created by the page editor), gating is set in the editor's Advanced section under "Require member subscription" — same dropdown shape, slightly different storage. The plan slug is encoded as ->middleware('page.member-plan:plan-slug') on the page's route in routes/web.php, and the middleware enforces per request. Member-required pages are forced uncached so the gate's redirect can't be served from cache to other visitors.
For model-backed content (ContentItem — blog, and every other content type), the required plan ID is stored on the record itself and enforced in the page's mount() via MemberContentAccess::enforce().
The "or higher" rule uses member_plans.sort_order within the same category. A page requiring Support Core (sort_order 10) lets Support Pro (20) and Elite (30) members through but blocks Support Core (10) members — wait, that's wrong, equal-or-higher means equal-or-higher: Support Core sees a Core-gated page just fine; a Hosting Small member doesn't see it (wrong category). The same category requirement is what makes "or higher" meaningful — a Pro support customer is implicitly entitled to whatever Core sees.
License-key server
When license-keys mode is on, every Member with a software-category line item gets a unique 32-character key on their dashboard. They paste it into the consuming software (most commonly: another WebProCMS install, via the install's CMS_LICENSE_KEY env var or its Memberships → Settings page).
The consuming software POSTs to /api/membership/check with:
Authorization: Bearer {their_license_key}- Body:
{"host": "their-domain.com", "version": "1.2.3"}
And gets back:
{
"active": true,
"tier": "members",
"expires_at": null
}
The endpoint is rate-limited to 60 requests per minute per IP and always returns 200 (even for unknown keys or inactive members) so a deauthed install can't drift indefinitely by polling and getting nothing back. The tier field returns the slug of the active software-category plan — the consumer can use it to differentiate behavior between subscription levels.
Every successful key match writes last_seen_at, last_seen_host, and last_seen_version to the Member record — even for cancelled members — so admins can spot deauthed installs still phoning home (security signal).
What "license-unlock" actually unlocks
The license endpoint returns active=true. What that boolean means is up to the consumer. The CMS doesn't bake any specific behavior in. webprocms.com interprets it as "premium CMS features unlock"; a yoga studio's iOS app could interpret it as "Premium Library content unlocks"; a SaaS vendor could interpret it as "the SDK works."
On a consuming WebProCMS install, the install runs cms:check-membership daily (or any other interval — it's a scheduled command). The command writes Setting::set('membership.is_member', $active) and Setting::set('membership.tier', $tier) based on the response. Anything in the codebase can then read Membership::isMember() to decide its behavior.
For reselling WebProCMS specifically, the Require membership for features toggle (Memberships → Settings, Setting key memberships.gate_features, off by default) adds a runtime filter to Features::enabled() that vetoes feature enablement when ! Membership::isMember(). It is turned on for distributed installs during provisioning. Other Member-feature installs leave it off, and their Features page works without membership.
Configuration
Per-install settings (Memberships → Settings)
- Content gating mode on/off
- License keys mode on/off
- Retention & failed payments — failed-payment email toggle, save-offer Stripe coupon ID + member-facing label, and the longest self-serve pause (months)
- License key — what this install's daily check sends as its Bearer token. Overrides
CMS_LICENSE_KEYenv var when set. Leave blank to fall back to env. - Membership API URL — where this install phones home. Overrides
CMS_MEMBERSHIP_API_URLenv var. Leave blank to fall back to env (defaults tohttps://www.webprocms.com/api/membership/check).
Stripe credentials
Preferred: dashboard settings, which take precedence over env.
- Site-wide key pair — Dashboard → Settings → API Keys → Stripe Keys (
stripe.key/stripe.secret). Shared by every payment-taking feature. - Webhook signing secret — Dashboard → Settings → Memberships (
stripe.webhook_secret.memberships). Stripe issues one signing secret per webhook endpoint, so each feature that registers its own endpoint keeps its secret on its own settings page, namespaced asstripe.webhook_secret.{feature}.
Env fallback (used when the matching Setting is blank — handy for headless/scripted setups):
MEMBERSHIPS_STRIPE_KEY— publishable keyMEMBERSHIPS_STRIPE_SECRET— secret keyMEMBERSHIPS_STRIPE_WEBHOOK_SECRET— for webhook signature verification
Dedicated env vars (not the generic Stripe ones) so an install can have other Stripe wiring elsewhere without collision. Resolution happens in MembershipsStripe::resolveCredential() — Setting first, env second.
Tools page
Dashboard → Tools gains a Membership Status card showing the live state of the consuming side: current Membership::isMember(), tier, last-checked timestamp, truncated license key with (from dashboard) or (from .env) indicator, and the effective API URL. A Check Now button triggers the same MembershipClient::check() the daily scheduler runs, and displays the raw response inline for debugging.
Admin pages
All under Dashboard → Memberships (visible to admins when the feature is on):
- Members — paginated list with search (email / name / license key / last-seen host) and status filter. Each row shows last-seen timestamp + host so deauthed installs still phoning home are visible at a glance.
- Plans — CRUD per plan, grouped by category. Stripe price ID + custom-amount fields.
- Plan Categories — CRUD. The
is_license_unlockflag on a category is what makes its plans count for the/api/membership/checkactive=truedecision. - Settings — mode toggles + this install's license key + API URL (see above).
The New Member form lets admins manually create comp accounts without Stripe — no stripe_customer_id is set, so the Member can log in and use the dashboard but isn't billed. Useful for staff, comp customers, and end-to-end testing.
The Edit Member screen lets admins change status (active/paused/cancelled), edit admin notes, regenerate the license key, and see all line items (active + cancelled). For Stripe-backed members, line-item changes happen via the Stripe Dashboard — webhooks sync the changes back to the local record. For manually-created comp members, line items are read-only on the edit page.
Comping a customer (free subscription)
Three supported paths depending on whether the comp needs a specific plan/tier attached and whether you want it tracked in Stripe.
The Comp / Exempt switch (simplest — works on existing members)
For installs you host yourself or comp outright, where the member just needs to count as active on the license check and no specific paid tier matters.
- Dashboard → Memberships → Members → edit the member
- Toggle Comp / Exempt on, Save
While the member's status is Active, /api/membership/check reports them active=true with tier exempt (a real paid line item's tier still wins if one exists). No plan, no line items, no Stripe. The members list shows a Comp badge next to their status. To revoke, toggle it back off — setting the member's status to cancelled/paused also deactivates an exempt member.
Comp as a tier. Tier exempt unlocks every member feature but satisfies no requires_tier gate — so a plain comp can never enable the Enterprise-locked Fleet Management card or the developer tooling. When the comp needs a specific plan (an agency you host, your own agency site), pick it in the Comp as select that appears under the switch: the license check then reports that tier (pro / enterprise; member_profiles.exempt_tier) instead of exempt, and the badge reads Comp · Enterprise. A paid line item on the same member still wins. Switching the comp off clears the pinned tier.
This is the only way to comp an existing member with no Stripe subscription: line items can only be attached at member creation (Path A) or through Stripe (Path B).
Path A — Local-only comp (no Stripe, simplest)
For staff, beta testers, friends, or anyone where billing tracking doesn't matter.
- Dashboard → Memberships → Members → New Member
- Enter email + name; the form auto-generates a 16-char password (copy it to share, or have them reset via the forgot-password flow)
- Status: active
- Under "Assign plans (optional)" pick the plan you're comping (typically Members in the Software Features category — that's the one that flips
active=trueon the license check) - Make sure Generate license key is on
- Create Member
The member can immediately log in at /members/login, see their license key, and use the install normally. The "Manage billing" button doesn't appear on their dashboard because they have no Stripe customer — which they don't need.
To revoke: edit the member, set status to cancelled (or cancel the specific line item). No Stripe interaction needed.
Caveats: doesn't appear in Stripe reporting (it's purely local), and the member can't self-cancel because there's no Stripe subscription to cancel.
Path B — Stripe-tracked comp via 100% coupon
For comps you want visible in Stripe reporting — press, partners, key customers — where you want the "would be paying $X/mo" data point.
- Stripe Dashboard → Coupons → Create
- Type: Percentage discount
- Percent off: 100
- Duration: Forever
- Name something memorable like "Comp"
- Share the coupon code with the customer
- They sign up normally at
/members/register, picking the plan they'd otherwise pay for - At Stripe Checkout they paste the coupon code; their first invoice is $0 and a real subscription is created
- Webhook fires, our sync runs, they get a license key just like a paying customer
Or do it without involving the customer — create a Customer manually in Stripe Dashboard, attach a subscription with the coupon, and webhook will sync it into your local member list automatically.
Pros: real Stripe subscription, customer has Customer Portal access, comps show in Stripe revenue reports as discounted line items.
Cons: more moving parts; the customer has to complete a signup flow (or you do it for them in Stripe).
Which path to pick
- Installs you host / permanent comps where the tier doesn't matter: the Comp / Exempt switch. One toggle, works on existing members.
- A comp that must reach a tier-locked feature (Fleet Management, developer tooling): the Comp / Exempt switch plus Comp as → Enterprise.
- Internal team / quick favors that need a real plan: Path A. Faster, no Stripe noise.
- External comps you want to track: Path B. Better bookkeeping.
Don't use the "Custom Software" plan at $0 — it works but creates a one-off Stripe price object per comp, cluttering your Stripe account for no real benefit.
Webhook events handled
The webhook controller at /members/stripe/webhook (CSRF-exempted, signature-verified per request) dispatches these Stripe events:
| Event | Effect |
|---|---|
checkout.session.completed |
Run sync — create line items, generate license key if applicable |
customer.subscription.created |
Run sync |
customer.subscription.updated |
Run sync — handles plan swaps, line-item adds/removes done in Stripe Dashboard |
customer.subscription.deleted |
Mark member cancelled, soft-cancel all line items |
invoice.payment_failed |
Mark member paused; advance the dunning stage, capture Stripe's next retry time, send the escalation email |
invoice.payment_succeeded |
Clear paused status if member was previously paused; reset the dunning episode |
Signature verification failures return 400. Internal handler errors return 200 (logged) so Stripe doesn't retry on our bugs.
Extending memberships
The pattern is custom features that subscribe to membership state, not core modifications. The shape:
- A
manifest.jsondeclaring the feature - A ServiceProvider that boots when the feature is enabled, registers a runtime filter (
Features::registerEnableFilter()), and short-circuits when disabled - Filter checks
Membership::isMember()(or any other state) and returns a veto
(The core feature gate in MembershipsServiceProvider::registerMembershipFeatureGate() is the canonical example of such a filter — see the full developer guide in custom-features.md.)
Customers wanting their own pattern (e.g. "unlock an extra dashboard page when member") write a small custom feature, install it via the dashboard's Settings → Features → Custom tab using the ZIP upload, and toggle it on. The custom feature subscribes to whatever extension points it needs.
The core memberships module stays focused on the generic primitives (signup, billing, content gating, license keys); install-specific business rules live in install-specific code.
What it's not
- Not a course platform. Members can be gated to specific pages and posts, but there's no built-in lesson tracking, quiz engine, or completion certificates. Combine with the Courses feature (when shipped) for that.
- Not multi-install license sharing. One Member = one license key = one consuming install (Stripe-tier-wise). If a customer wants to authorize their license on two installs simultaneously, that's currently not supported in the v1 — they'd need separate Memberships.
- Not a tax/VAT engine. Stripe Tax can be enabled on the Stripe side; this module doesn't compute or display tax breakdowns.
- Not a coupon engine. Stripe Checkout's
allow_promotion_codesis enabled by default, so promo codes created in Stripe work. There's no in-CMS coupon builder. - Not legal advice. Cancellation timing, refund policies, and consumer-protection law vary by jurisdiction — admins are responsible for their own terms of service and consulting counsel when crossing borders.