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.
Starter
- 3 projects
- 1 seat
Team
- Unlimited projects
- 10 seats
- Audit log
Scale
- SSO
- Priority support
Usage and limits
Metered features from laravel-fms, showing remaining allowance before the gate denies.
Storage is over 90% — the gate returns a soft-limit warning before it denies.
The code
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// 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(),
],
],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
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-fmsModel 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.
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.
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.
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.
