trench94 / puppr
Feature-module access control for modular Laravel SaaS applications. Define modules, grant them to users or teams, and guard routes, views and gates.
Requires
- php: ^8.1
- illuminate/auth: ^10.0|^11.0|^12.0
- illuminate/console: ^10.0|^11.0|^12.0
- illuminate/contracts: ^10.0|^11.0|^12.0
- illuminate/database: ^10.0|^11.0|^12.0
- illuminate/http: ^10.0|^11.0|^12.0
- illuminate/routing: ^10.0|^11.0|^12.0
- illuminate/support: ^10.0|^11.0|^12.0
- illuminate/view: ^10.0|^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0
- phpunit/phpunit: ^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-11 15:49:41 UTC
README
Puppr
Feature-module access control for modular Laravel SaaS applications.
Define the modules your product is made of, grant them to users, teams or tenants, and guard your routes, views and gates with a single line.
Why Puppr?
Most SaaS products are sold as a set of features: Billing, Reports, API access, Chat... Roles and permissions describe what a user may do, but they get awkward when you need to describe which parts of the product a customer has bought or been given.
Puppr models exactly that. A module is a named feature of your application. You grant modules to users (or teams, tenants, organisations, anything Eloquent). Puppr then answers one question everywhere in your app:
Is this module switched on, and does this user have it?
- 🧩 Modules are plain Eloquent records: create them in a seeder, an admin panel or from the console.
- 🔌 Kill switch: deactivate a module and it disappears for everyone, without touching a single grant.
- 👥 Polymorphic grants: give modules to users today and to teams tomorrow, with the same trait.
- 🛡️ Guards everywhere: route middleware, gate ability, Blade directives, and a facade for everything else.
- 🖥️ Console tooling: create, list, activate, grant and revoke modules from artisan.
- 🪶 No opinions about billing, plans or roles. Puppr composes with Cashier, Spatie Permission or anything else you already use.
Requirements
| Puppr | PHP | Laravel |
|---|---|---|
| 1.x | 8.1+ | 10, 11, 12 |
Installation
Install the package with Composer:
composer require trench94/puppr
Publish the config file and migration, then migrate:
php artisan puppr:install php artisan migrate
Finally, add the HasModules trait to any model that can be granted modules. Usually that is your User model:
use Illuminate\Foundation\Auth\User as Authenticatable; use Trench94\Puppr\Concerns\HasModules; class User extends Authenticatable { use HasModules; }
That's it. Puppr auto-registers its service provider, facade, middleware, gate ability and Blade directives.
Quick start
use Trench94\Puppr\PupprFacade as Puppr; // 1. Define the modules your product offers Puppr::create('Billing'); Puppr::create('Advanced Reports', ['description' => 'Charts, exports and scheduled reports']); // 2. Grant them $user->grantModule('billing', 'advanced-reports'); // 3. Check them $user->hasModule('billing'); // true Puppr::allows($user, 'advanced-reports'); // true // 4. Guard things Route::middleware('module:billing')->get('/invoices', InvoiceController::class);
@module('advanced-reports') <a href="{{ route('reports') }}">Reports</a> @endmodule
Usage
Creating modules
A module has a name, a URL-friendly slug (generated from the name), an optional description and an active flag. Modules are referenced by slug or name everywhere, so 'Advanced Reports' and 'advanced-reports' are interchangeable.
Puppr::create('Billing'); // active by default Puppr::create('Beta Chat', ['active' => false]); // hidden until you flip it on Puppr::create('Reports', ['slug' => 'rpt']); // custom slug
create() is idempotent, so it is safe to call from a seeder or a deploy script. You can also use the model directly:
use Trench94\Puppr\Models\Module; Module::create(['name' => 'Billing']); Module::active()->get(); Module::named('billing')->first();
Or from the console:
php artisan puppr:make "Advanced Reports" --description="Charts and exports" php artisan puppr:make "Beta Chat" --inactive php artisan puppr:list
Granting and revoking
Any model using HasModules gets a fluent API. Modules can be referenced by slug, name, id or Module instance, individually or as an array:
$user->grantModule('billing'); $user->grantModule('billing', 'reports'); $user->grantModule(['billing', 'reports']); $user->grantModule($module); $user->revokeModule('reports'); $user->revokeAllModules(); $user->syncModules('billing', 'chat'); // replace the whole set $user->modules; // Eloquent collection of granted modules $user->activeModules(); // only the ones currently switched on
The same works through the facade, which is handy in services and jobs:
Puppr::grant($team, 'billing'); Puppr::revoke($team, 'billing');
Granting an unknown module throws a ModuleNotFoundException, so typos surface immediately. Granting the same module twice is a no-op.
From the console:
php artisan puppr:grant billing 42 # user id 42 php artisan puppr:revoke billing 42 php artisan puppr:grant billing 7 --model="App\Models\Team"
Checking access
A module check passes only when all three are true: the module exists, the module is active, and the accessor has been granted it. Guests (null) are always denied.
$user->hasModule('billing'); // granted AND active $user->hasAllModules('billing', 'reports'); $user->hasAnyModule('billing', 'reports'); $user->isGrantedModule('billing'); // granted, even if the module is inactive Puppr::allows($user, 'billing'); Puppr::allows($user, 'billing', 'reports'); // all required Puppr::allowsAny($user, 'billing', 'reports'); // any is enough Puppr::denies($user, 'billing'); Puppr::authorize($user, 'billing'); // throws ModuleAccessDeniedException (403) Puppr::authorizeAny($user, 'billing', 'reports');
ModuleAccessDeniedException extends Laravel's AuthorizationException, so an uncaught one becomes a normal 403 response. Call $e->modules() to see which modules were checked.
Route middleware
Two middleware aliases are registered. Separate multiple modules with commas (or pipes):
// Requires ALL listed modules Route::middleware('module:billing')->group(function () { ... }); Route::middleware('module:billing,reports')->get('/dashboard', ...); // Requires ANY of the listed modules Route::middleware('module.any:billing,reports')->get('/exports', ...);
Prefer class references? Use the helper:
use Trench94\Puppr\Http\Middleware\EnsureModuleAccess; Route::middleware(EnsureModuleAccess::using('billing', 'reports'))->get(...);
Puppr reads the user from $request->user(), so place the middleware after auth (or use it on authenticated groups).
Gate ability
Puppr registers a module gate ability, so all of Laravel's authorization helpers just work:
$user->can('module', 'billing'); $user->can('module', ['billing', 'reports']); // all required $user->cannot('module', 'chat'); Gate::authorize('module', 'billing'); // in a controller $this->authorize('module', 'billing'); // with AuthorizesRequests
@can('module', 'billing') ... @endcan
Blade directives
@module('billing') <x-nav-link :href="route('billing')">Billing</x-nav-link> @elsemodule <x-upgrade-banner module="billing" /> @endmodule @module('billing', 'reports') {{-- all required --}} ... @endmodule @anymodule('billing', 'reports') ... @endanymodule @unlessmodule('chat') <p>Chat is not part of your plan.</p> @endmodule
Switching modules on and off
Deactivating a module hides it from everyone without touching their grants. Reactivate it and all grants come back. Perfect for maintenance windows, gradual rollouts or pulling a feature quickly.
Puppr::deactivate('beta-chat'); Puppr::isActive('beta-chat'); // false Puppr::activate('beta-chat'); $module->deactivate(); $module->activate();
php artisan puppr:deactivate beta-chat php artisan puppr:activate beta-chat
Super admins and other bypasses
Sometimes a user should see every active module regardless of grants. Register a bypass callback in a service provider; return true to allow, or null/false to fall through to the normal check:
use Trench94\Puppr\PupprFacade as Puppr; public function boot(): void { Puppr::bypassUsing(fn ($user, $module) => $user?->is_super_admin); }
Bypasses never unlock inactive modules. The kill switch always wins.
Granting modules to teams, tenants or organisations
HasModules works on any Eloquent model because grants are stored in a polymorphic pivot. To scope access by team rather than by user:
class Team extends Model { use HasModules; } $team->grantModule('billing'); $team->hasModule('billing'); Route::middleware('module:billing') // still checks $request->user()
If your checks should run against the current team instead of the user, wrap it in a bypass or write a small middleware that calls Puppr::authorize($request->user()->currentTeam, ...). You can also look at a module from the other side:
$module->accessors(User::class)->get(); // every user granted this module $module->accessors(Team::class)->count();
Configuration
php artisan puppr:install publishes config/puppr.php:
| Key | Default | Purpose |
|---|---|---|
model |
Trench94\Puppr\Models\Module |
Swap in your own Module subclass. |
tables.modules |
modules |
Table that stores modules. |
tables.module_access |
module_access |
Polymorphic pivot table for grants. |
user_model |
App\Models\User |
Default model for puppr:grant / puppr:revoke. |
throw_on_missing_module |
false |
Throw instead of denying when a check names an unknown module. Handy in development. |
middleware.all / middleware.any |
module / module.any |
Middleware aliases. Set to null to skip registration. |
gate_ability |
module |
Gate ability name. Set to null to disable. |
blade_directives |
true |
Register the Blade directives. |
Change the table names before running the migration.
Artisan commands
| Command | Description |
|---|---|
puppr:install [--force] [--migrate] |
Publish config and migration (optionally run migrations). |
puppr:make {name} [--description=] [--inactive] |
Create a module. |
puppr:list [--active] |
Show all modules in a table. |
puppr:activate {module} |
Switch a module on. |
puppr:deactivate {module} |
Switch a module off for everyone. |
puppr:grant {module} {id} [--model=] |
Grant a module to a user (or other model). |
puppr:revoke {module} {id} [--model=] |
Revoke a module. |
Testing
composer install
composer test
The suite runs against an in-memory SQLite database using Orchestra Testbench. CI runs it on PHP 8.1 to 8.4 across Laravel 10, 11 and 12.
Changelog
See CHANGELOG.md for what has changed recently.
Contributing
Contributions are welcome! Please read CONTRIBUTING.md first. Bugs and ideas go in the issue tracker.
Security
If you discover a security issue, please follow the process in SECURITY.md instead of opening a public issue.
Credits
License
Puppr is open-source software licensed under the MIT license.