All use cases
Build this app

A subscription SaaS app

Plans, checkout, per-plan feature gating and metered usage — with the catalog in your database rather than the payment processor.

The problem

Every subscription app rebuilds the same four things: a pricing page, a checkout, a way to ask "is this customer allowed to do this?", and a meter for whatever you sell by the unit. The first two are usually fine. The third leaks — plan checks end up as if ($user->plan === 'pro') scattered across controllers, so adding a plan means finding every one of them.

The fourth is worse. Metered limits get enforced in three places with three different answers: the UI shows a quota, the API enforces a different one, and the invoice is computed from a third. The customer notices before you do.

What you are building

Live surfaces, not screenshots — every one composed from the same components you would install. Laravel + Inertia. The catalog and the gate are separate packages on purpose, so the pricing page and the enforcement can read the same source without one importing the other.

Plan picker

The vendorable catalog-fms block — copied into your project, then yours to restyle.

Plans

Starter

$19.00/mo
  • 3 projects
  • 1 seat
Most popular

Team

$49.00/mo
  • Unlimited projects
  • 10 seats
  • Audit log

Scale

$149.00/mo
  • SSO
  • Priority support

Usage and limits

Metered features from laravel-fms, showing remaining allowance before the gate denies.

Usage this periodTeam
8,420
API calls
6 / 10
Seats
92%
Storage
API calls84%
Seats60%
Storage92%

Storage is over 90% — the gate returns a soft-limit warning before it denies.

The code

A plan is a product with a recurring pricephp
use LaravelCatalog\Facades\Catalog;

// There is no separate Plan model to keep in step with the product -- which is
// the usual source of drift between what you sell and what you charge.
$team = Catalog::createProduct(['name' => 'Team', 'description' => 'For small teams']);

Catalog::createPrice($team, [
    'amount'    => 4900,                        // cents
    'currency'  => 'usd',
    'recurring' => ['interval' => 'month'],
]);

Catalog::syncProductAndPrices($team);           // outward to Stripe; queue it in production
Define entitlement once, in configphp
// config/fms.php -- one definition, so adding a plan is not a hunt for
// `$user->plan === '...'` across the codebase.
'features' => [
    'audit-log' => [
        'name'    => 'Audit log',
        'type'    => 'boolean',
        'enabled' => fn ($user) => $user->subscribedToProduct('team'),
    ],

    'api-calls' => [
        'name'  => 'API calls',
        'type'  => 'resource',
        'limit' => fn ($user) => $user->onPlan('scale') ? 500_000 : 10_000,
        'usage' => fn ($user) => $user->apiCalls()->thisPeriod()->count(),
    ],
],
Enforce at the edge, mirror in the UIphp
use ParticleAcademy\Fms\Facades\FMS;
use ParticleAcademy\Fms\Http\Middleware\RequireFeature;

Route::middleware(['auth', RequireFeature::class.':audit-log'])
    ->get('/audit', [AuditController::class, 'index']);

// The SAME question in the page payload, so the UI hides what the middleware
// would refuse instead of offering a button that 403s.
return Inertia::render('Dashboard', [
    'can' => [
        'auditLog'     => FMS::canAccess('audit-log'),
        'apiRemaining' => FMS::remaining('api-calls'),
    ],
]);

How to solve it

  1. Install the catalog and the gate

    Two packages: one owns products and prices, the other owns "is this allowed?". Keeping them separate is what lets the pricing page and the enforcement agree without a circular dependency.

    Run thisbash
    composer require particle-academy/laravel-catalog particle-academy/laravel-fms
  2. Model plans as products with recurring prices

    Every product needs at least one price before it can sync. There is no second model to keep honest.

  3. Define features once

    Boolean for on/off, resource for anything metered. Both take callables, so entitlement is derived from the subscription rather than copied onto the user record where it goes stale.

  4. Answer the question in one place

    Middleware is the boundary; the payload is the hint. Because both call the same facade, the UI cannot offer something the route refuses.

  5. Build pricing from your own database

    The catalog is local, so the pricing page needs no API call on load and cannot disagree with what checkout charges.