ForgeSaas

A SaaS layer your app adds on top of multi-tenancy — plans, subscriptions, and feature gating — so you can charge differently and limit what each tenant can do. Built to sit on ForgeMultiTenant, which it turns into a true multi-plan product.

Overview

ForgeSaas gives your app the commercial machinery of a SaaS product: a catalog of plans, a subscription that ties each tenant to one plan, and a way to gate features and enforce usage limits against that plan. It checks what the current tenant is allowed to do and enforces it for you — either automatically at the routing layer or explicitly from your own code.

What it gives you

  • Plans with feature flags and limits
  • Per-tenant subscriptions with status
  • Attribute-driven feature gating
  • Enforced usage limits (403 when hit)
  • Global helpers for your views and logic
  • CLI to create plans and assign tenants

Prerequisites: ForgeSaas requires the routing layer (for its middleware) and ForgeDatabaseSQL (for the plan and subscription tables). It resolves the active tenant through the tenant attribute that ForgeMultiTenant sets on each request, so pair them together.

Multi-Tenant vs. SaaS

The two components solve different problems, and you choose how far to take your product:

ForgeMultiTenant — isolation only

Multiple customers, one codebase, data kept apart. Every tenant resolves, gets its own scoped data and connection, and your business code works against the current tenant. There are no plans, no subscriptions, no feature limits. Every tenant is implicitly equal.

ForgeSaas — the commercial layer

Adds the business model: a catalog of plans, a subscription linking each tenant to a plan, and enforcement of what that plan allows. This is where "this tenant is on the Free plan, so it can't use SSO" and "the Pro plan allows 25 users" live.

In practice, ForgeSaas is designed to sit directly on ForgeMultiTenant — its middleware consumes the resolved tenant and its subscriptions are keyed by tenant id. But the two are independent decisions:

  • You need isolated customers but not a paywall: install only ForgeMultiTenant. Tenants are all treated the same; there is no concept of a plan. A team collaboration tool or an internal product that hosts many organizations often stops here.
  • You sell tiers with different capability: add ForgeSaas on top. Now each tenant has a plan, and you control which features and limits apply to which tenants. This is the full SaaS posture.

Limiting a feature does not mean a SaaS: if your app needs to restrict what some users can do — even without multi-tenancy or billing — ForgeSaas still gives you the plan/feature/limit vocabulary to model it. But its natural home is on top of a multi-tenant app.

Installation

php forge.php package:install-module --module=ForgeSaas

Installing migrates and seeds the module's tables and registers its middleware on the web route group. Two tables back the component:

  • saas_plans — the plan catalog, each with a slug, its feature list (as JSON), and its usage limits (as JSON).
  • tenant_subscriptions — one row (at most) per tenant, linking a tenant id to a plan with a status and optional trial/period timestamps.

The component reads these from the app's central database, so plan and subscription data stays shared across every tenant rather than living inside any one tenant's data.

Plans

A plan is a named, versioned-free tier with two dimensions that the rest of the system enforces:

  • Features — a list of capability flags the plan includes (e.g. api_access, custom_domain, sso). A tenant's plan either has a feature or it doesn't; this is the "can it do X?" check.
  • Limits — numeric ceilings keyed by resource name (e.g. max_users, and max_storage_gb). A limit of -1 means unlimited; a resource with no entry is treated as effectively unlimited.

Installing seeds three starter plans you can edit or replace with your own:

# saas_plans (seeded)
# Free      — no features;         limits: max_users=3,       max_storage_gb=1
# Pro       — advanced_reports,    api_access, custom_domain; max_users=25,      max_storage_gb=20
# Enterprise — everything + white_label, priority_support, sso; unlimited         (–1, –1)

Create your own from code or the command line. Deleting a plan that still has subscriptions is refused; disabling a plan keeps its subscriptions but marks it inactive.

Subscriptions

A subscription ties a tenant to a plan. Each tenant holds at most one, with a status drawn from a small enum: active, trial, past_due, and canceled. A subscription also carries optional trial_ends_at and current_period_ends_at timestamps.

Assign or change a tenant's plan from the command line, or from your code via the subscription manager:

# Assign the Pro plan to a tenant
php forge.php modules:saas:tenant:assign --tenant=upper --plan=plan-pro

A subscription is considered active when its status is active or trial. The manager resolves the current tenant's subscription at the start of a request, so feature checks throughout that request see a consistent picture.

Gating Features & Limits

Mark a route handler (or a whole controller) with a gate attribute, and the feature-gate middleware answers it against the current tenant's plan — returning a clear 403 when the plan doesn't allow it:

use Modules\ForgeSaas\Attributes\RequiresFeature;
use Modules\ForgeSaas\Attributes\RequiresPlan;
use Modules\ForgeSaas\Attributes\WithinLimit;

#[RequiresFeature('api_access')]
final class ApiController
{
    #[RequiresPlan('enterprise')]
    public function export() { /* ... */ }

    #[WithinLimit(resource: 'max_users', table: 'users')]
    public function invite() { /* ... */ }
}
  • RequiresFeature — the tenant's plan must include the named feature.
  • RequiresPlan — the tenant must be on the named plan (by slug).
  • WithinLimit — counts the rows in a tenant-scoped table and checks it against the plan's limit for that resource, refusing once the ceiling is reached.

Attributes placed on a method take precedence over the same attribute on the class. When a gate's handler isn't inside a resolved tenant, the middleware lets the request through unchanged — so central-domain routes are unaffected.

From Your Code

Outside the routing layer, a set of small helpers answer the same questions anywhere in your app — controllers, views, or services:

tenant_can('api_access');              // bool — plan includes the feature
tenant_on_plan('enterprise');          // bool — on the named plan
tenant_subscription_active();          // bool — status is active/trial
tenant_within_limit('max_users', 24);  // bool — under the plan's ceiling
tenant_limit('max_storage_gb');        // int  — the plan's limit, or -1 / unlimited
tenant_plan();                         // ?SaasPlan — the active tenant's plan

The underlying SubscriptionManager (resolvable from the container as SubscriptionManagerInterface) exposes the same checks plus plan and subscription management. Build the manager for a specific tenant with forTenant($tenant) and it loads that tenant's subscription for the rest of the call:

use Modules\ForgeSaas\Contracts\SubscriptionManagerInterface;

$saas = $container->get(SubscriptionManagerInterface::class);

$manager = $saas->forTenant($tenant);
$manager->hasFeature('sso');        // bool
$manager->limitFor('max_users');    // int

// Manage plans and subscriptions
$manager->getAllPlans();
$manager->createPlan('Premium', 'premium', ['api_access'], ['max_users' => 50]);
$manager->disablePlan('plan-free');
$manager->assignPlanToTenant($tenantId, 'plan-pro');
$manager->deletePlan('plan-free'); // refused while it has subscriptions

Use these same primitives to build your own billing, upgrade, or checkout flow — the component leaves payment itself to you; it models the plan, the subscription, and the enforcement.

CLI Commands

Manage plans and assign tenants from the command line:

# Plans
php forge.php modules:saas:plan:list
php forge.php modules:saas:plan:create --name=Premium --slug=premium \
    --features=api_access,custom_domain --limits='{"max_users": 25}'
php forge.php modules:saas:plan:disable --id=plan-free
php forge.php modules:saas:plan:delete --id=plan-old

# Assign a plan to a tenant
php forge.php modules:saas:tenant:assign --tenant=upper --plan=plan-pro

Plan creation and assignment accept interactive prompts, so you can run them bare and answer the questions as they come.

Configuration

There are no required settings. The component picks up the active tenant automatically from the request attribute that the multi-tenant stack sets, and reads plans and subscriptions from the central database. Define your plan catalog and assign tenants — either during onboarding from code or via the CLI — and the rest of the component works from there.

To see the whole flow wired together — tenant resolution, then plan evaluation on top of it — read the ForgeMultiTenant page, which this component extends.