particle-academy / laravel-fms
Laravel Feature Management System (FMS) for product feature entitlements and usage tracking
Package info
github.com/Particle-Academy/laravel-feature-management-system
pkg:composer/particle-academy/laravel-fms
Requires
- php: ^8.2
- laravel/framework: ^11.0|^12.0|^13.0
- nesbot/carbon: ^2.0|^3.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
README
Laravel Feature Management System (FMS)
A standalone Laravel package for flexible feature access control and management. FMS provides simple, intuitive ways to control feature access using multiple strategies: Gates/Policies, config-based, registry-based, and database lookups.
Features
- Multiple Access Control Strategies: Gates/Policies, config files, feature registry, or database
- Boolean & Resource Features: Support for simple on/off features and metered resource features
- Feature Groups: Bundle features into reusable groups (Pro plan, Enterprise, AI Beta cohort, etc.) and assign them polymorphically to any model
- Middleware Protection: Protect routes based on feature access
- Facade & Helpers: Clean API via facade and global helper functions
- Standalone Package: Zero dependencies on other packages
- Configurable schema: Override the
feature_usages/subscriptions/product_featurestable names without forking - Laravel 13 Compatible: Built for Laravel 11+, 12+, and 13+
Installation
composer require particle-academy/laravel-fms
The package will auto-discover and register its service provider.
Configuration
Publish the configuration file:
php artisan vendor:publish --tag=fms-config
Define your features in config/fms.php:
return [ 'features' => [ // Simple boolean feature 'use-mcp' => [ 'name' => 'Use MCP', 'description' => 'Access to MCP-powered assistants and tools.', 'type' => 'boolean', 'enabled' => true, // or callable: fn($user) => $user->isPremium() ], // Resource feature with limit 'ai-tokens' => [ 'name' => 'AI Tokens', 'description' => 'Metered AI token usage per billing period.', 'type' => 'resource', 'limit' => 10000, // or callable 'usage' => fn($user) => $user->getTokenUsage(), // optional ], ], ];
Usage
Using the Facade
use ParticleAcademy\Fms\Facades\FMS; // Check if feature is accessible if (FMS::canAccess('use-mcp')) { // Feature is enabled } // Check if feature is enabled (alias) if (FMS::isEnabled('use-mcp')) { // Feature is enabled } // Check if user has feature if (FMS::hasFeature('use-mcp', $user)) { // User has access } // Get remaining quantity for resource features $remaining = FMS::remaining('ai-tokens', $user); if ($remaining > 0) { // Allow action } // Get all enabled features $enabled = FMS::enabled($user);
Using Helper Functions
// Get feature manager or check feature if (feature('use-mcp')) { // Feature is enabled } // Check feature access if (can_access_feature('use-mcp', $user)) { // User has access } // Check if feature is enabled if (feature_enabled('use-mcp')) { // Feature is enabled } // Get remaining quantity $remaining = feature_remaining('ai-tokens', $user); // Get all enabled features $enabled = enabled_features($user);
Feature Groups
Big apps grow lots of features, and remembering which to flip on for which plan/tier becomes tedious. Feature groups bundle features under a single key, and any model that uses the HasFeatureGroups trait can be polymorphically assigned to one or more groups.
Define groups in config/fms.php
'groups' => [ 'pro-plan' => [ 'name' => 'Pro Plan', 'features' => ['use-mcp', 'ai-tokens', 'team-sharing'], 'overrides' => [ 'ai-tokens' => ['limit' => 50000], // lift the base limit ], ], 'enterprise' => [ 'name' => 'Enterprise', 'extends' => ['pro-plan'], // one level deep 'features' => ['sso', 'audit-log'], 'overrides' => [ 'ai-tokens' => ['limit' => 250000], ], ], 'ai-beta' => [ 'name' => 'AI Beta cohort', 'features' => ['experimental-llm'], 'enabled' => fn ($user) => $user?->in_ai_beta === true, // callable gate, no pivot needed ], ],
Make a model assignable
use ParticleAcademy\Fms\Concerns\HasFeatureGroups; class User extends Authenticatable { use HasFeatureGroups; }
Assign + check
$user->attachFeatureGroup('pro-plan'); $user->attachFeatureGroup('enterprise'); FMS::canAccess('use-mcp', $user); // true (via pro-plan) FMS::remaining('ai-tokens', $user); // 250000 (max of all enabled groups) FMS::enabledGroupsFor($user); // ['pro-plan', 'enterprise'] FMS::explain('use-mcp', $user); // ['source' => 'group', 'detail' => ['groups' => ['pro-plan', 'enterprise'], ...]]
Resolution semantics
A feature is enabled if any of these returns true:
Gate::has($feature)is defined and grants access (Gate is authoritative — also the only path that can DENY)- The feature's registry definition has
enabled: trueor itscheckreturns true - Any enabled feature group containing this feature (NEW)
- The config file has
enabled: truefor this feature
For resource features, the limit is the MAX across:
- All enabled groups providing an override for this feature
- The base feature's own limit
So a "Pro" plan with ai-tokens.limit = 50000 lifts the base config's 1000 to 50000 for users in that group, while users not in any group still see the base limit.
Catalog integration
LaravelCatalog\Models\Product uses HasFeatureGroups, so a Stripe Product can be tagged with feature groups directly:
$product->attachFeatureGroup('pro-plan');
The implication: a user subscribed to that product gets every feature in the group (assuming your subscription resolution layer reads Product::featureGroups()).
Debugging tools
Two artisan commands make "why is this on/off?" trivial:
php artisan fms:groups # list all registered groups php artisan fms:groups pro-plan # inspect one group (resolved features + overrides) php artisan fms:resolve 42 # explain every feature for User #42 php artisan fms:resolve 42 --feature=use-mcp # explain a single feature php artisan fms:resolve org-7 --type=App\\Models\\Org # any HasFeatureGroups model
fms:resolve walks every feature and reports which source resolved it (gate / registry / group / config / none) plus the structured detail (matching groups, limit overrides, etc.).
Using Middleware
Protect routes with feature requirements:
use ParticleAcademy\Fms\Http\Middleware\RequireFeature; Route::middleware(['auth', RequireFeature::class . ':use-mcp'])->group(function () { Route::get('/mcp', [McpController::class, 'index']); }); // Multiple features (OR logic - user needs at least one) Route::middleware(['auth', RequireFeature::class . ':feature1,feature2'])->group(function () { // Route protected by feature1 OR feature2 });
Using Gates/Policies
FMS automatically checks Laravel Gates if they exist:
// In AuthServiceProvider Gate::define('use-mcp', function ($user) { return $user->subscription->plan === 'pro'; }); // FMS will automatically use this gate if (FMS::canAccess('use-mcp')) { // Gate check passed }
Feature Registry
Register features programmatically:
use ParticleAcademy\Fms\Services\FmsFeatureRegistry; app(FmsFeatureRegistry::class)->register('custom-feature', [ 'name' => 'Custom Feature', 'type' => 'boolean', 'enabled' => fn($user) => $user->hasPermission('custom'), ]);
Access Control Strategies
FMS checks features in this order:
- Pre-strategies (app-registered) - Run before Gate; first non-null verdict wins
- Gates/Policies - If a Gate exists with the feature name, it's checked first
- Feature Registry - Checks registered features via
FmsFeatureRegistry - Feature Groups - Any enabled group containing the feature flips it on
- Config File - Checks
config/fms.features.{feature} - Database - If
FeatureUsagemodel exists, checks database (extensible)
Pre-strategies (v0.6.0+)
When you need an external system — a billing service, a remote
entitlements provider, a feature flag platform — to be authoritative
about access (even over a Gate), register a pre-strategy. Strategies
receive (feature, user, context) and return ?bool:
true— granted, no further checksfalse— denied, no further checksnull— "I don't know", fall through to the next strategy / chain
use ParticleAcademy\Fms\Services\FeatureManager; // In an app service provider's boot(): app(FeatureManager::class)->registerPreStrategy('subscription', function ($feature, $user, $context): ?bool { if (! $user) return null; $sub = app(BillingService::class)->subscriptionFor($user); if (! $sub) return null; // no subscription -> fall through return $sub->allowsFeature($feature); // authoritative when subscription exists });
Strategies run in registration order, and explain() will report
source: 'pre-strategy' with the strategy name so devtools can show
"blocked by subscription" etc.
For resource features, register a ?int counterpart that answers
remaining():
app(FeatureManager::class)->registerPreRemainingStrategy('subscription-quota', function ($feature, $user, $context): ?int { $sub = app(BillingService::class)->subscriptionFor($user); return $sub?->remainingFor($feature); // null falls through });
Re-registering the same name replaces the strategy.
unregisterPreStrategy('subscription') and
unregisterPreRemainingStrategy('subscription-quota') undo it
(useful in tests).
Resource Features
Resource features support metered usage:
'api-calls' => [ 'type' => 'resource', 'limit' => 1000, 'usage' => fn($user) => $user->apiCalls()->thisMonth()->count(), 'remaining' => fn($user) => 1000 - $user->apiCalls()->thisMonth()->count(), // optional ],
Custom table names (v0.7.0+)
The feature_usages table and the two tables its foreign keys point at
are config-driven, so you don't have to fork the package or hand-edit a
published migration when your schema differs. Override any of them in
config/fms.php:
'tables' => [ 'feature_usages' => 'fms_feature_usages', // prefixed schema 'subscriptions' => 'subscriptions', // your billing table 'product_features' => 'catalog_product_features', // laravel-catalog's table ],
Both the FeatureUsage model (getTable()) and the create migration
read these values, so model and schema stay in sync from one change.
The create migration also self-skips (no error) when:
- the
feature_usagestable already exists (e.g. created by an older fork under a different name), or - either FK target table (
subscriptions/product_features) is missing at apply time.
The second guard means the migration can sit harmlessly early in your chronological migration order — if your subscription / product-feature tables are created later, FMS just defers, and you can build the real usages table in your own migration if you prefer.
Requirements
- PHP 8.2+
- Laravel 11+, 12+, or 13+
Testing
Run tests using Pest:
pkg laravel-fms php vendor/bin/pest
Integration with Laravel Catalog
FMS integrates seamlessly with Laravel Catalog for feature-based product management. When both packages are installed, Catalog automatically configures FMS to use Catalog's ProductFeature model.
Quick Integration Setup
- Install both packages:
composer require particle-academy/laravel-fms composer require particle-academy/laravel-catalog
- Configure FMS features in
config/fms.php:
return [ 'features' => [ 'manage-products' => [ 'name' => 'Manage Products', 'type' => 'boolean', 'enabled' => fn($user) => $user->hasRole('admin'), ], ], ];
- Use FMS in your Catalog controllers:
use ParticleAcademy\Fms\Facades\FMS; use LaravelCatalog\Models\Product; class ProductController extends Controller { public function store(Request $request) { if (!FMS::canAccess('manage-products')) { abort(403); } $product = Product::create($request->validated()); // ... } }
Product Features Integration
Catalog's ProductFeature model works with FMS to provide feature-based access control:
use LaravelCatalog\Models\Product; use LaravelCatalog\Models\ProductFeature; // Attach features to products $product = Product::find($productId); $feature = ProductFeature::where('key', 'advanced-editing')->first(); $product->productFeatures()->attach($feature->id, [ 'enabled' => true, 'included_quantity' => 100, ]); // Check feature access for user's subscription if (FMS::canAccess('advanced-editing', $user)) { // User has access via their subscription }
Subscription-Based Feature Access
When integrated with Catalog, you can check feature access based on user subscriptions:
use ParticleAcademy\Fms\Facades\FMS; // Check if user's subscription includes a feature $user = auth()->user(); $subscription = $user->subscriptions()->active()->first(); if ($subscription) { $product = $subscription->product(); // Check if product has feature and user has access foreach ($product->productFeatures as $feature) { if (FMS::canAccess($feature->key, $user)) { // Feature is available } } }
Example: Feature-Gated Product Actions
use ParticleAcademy\Fms\Facades\FMS; use LaravelCatalog\Facades\Catalog; class ProductController extends Controller { public function sync(Product $product) { // Check if user can sync products if (!FMS::canAccess('sync-products', auth()->user())) { abort(403, 'You do not have permission to sync products.'); } Catalog::syncProductAndPrices($product); return redirect()->back()->with('success', 'Product synced.'); } public function create() { // Check remaining product creations $remaining = FMS::remaining('product-creations', auth()->user()); if ($remaining <= 0) { return redirect()->back() ->with('error', 'Product creation limit reached.'); } return view('admin.products.create'); } }
Protecting Catalog Routes
Use FMS middleware to protect catalog admin routes:
use ParticleAcademy\Fms\Http\Middleware\RequireFeature; Route::prefix('admin')->middleware([ 'auth', RequireFeature::class . ':manage-products' ])->group(function () { Route::resource('products', ProductController::class); });
For more detailed integration examples and patterns, see INTEGRATION.md.
License
MIT