Access per scope set, not per tenant.
One assignment covers the whole tenant or a chosen list of scopes.
grant.php selects the scopes.Scoped access profiles for multi-tenant Laravel apps. Deny wins. Every grant records who gave it and why.
composer require seisigmasrl/laravel-access-profilesPHP 8.3+ · Laravel 12.69+ or 13.30+
$assignment = Access::grant('reviewer')->to($user)->in($team) ->onlyScopes([$site, $otherSite])->titled('Review cover') ->validFrom(now())->validUntil(now()->addMonths(6)) ->by($admin)->because('Cover leave')->save();Manager in two sites, read-only in a third. A broad grant in billing, except approving payments. Access needs to describe where a person can act, and where it stops.
Access can be temporary or appointed.An external auditor until a date. A title such as “Board treasurer”.
A broad grant can have one hole.Everything in billing, except approving payments.
“Who can approve payments in site 4?”That must be a query, not a loop over every user.
“Who gave this person access, and why?”The answer belongs with the assignment.
Tenants, scopes and holders are your own Eloquent models. Profiles combine allow and deny rules. Assignments connect them.
One assignment covers the whole tenant or a chosen list of scopes.
grant.php selects the scopes.Any matching deny beats every allow, in any order. With no rule, the answer is false.
profile.php combines allow and deny.Start dates are inclusive, end dates exclusive. The cache TTL stops at the next boundary.
grant.php sets the validity window.Each assignment stores the grantor and the reason. Revoked rows stay as history. Write events fire after commit.
grant.php records who and why.query.php retrieves history.Access::holders() applies deny, scope, validity and conditions, and returns a builder you can paginate.
query.php asks who has access.Guards reject self-assignment and escalation. Scopes must belong to the tenant. Rejections write nothing.
grant.php names the tenant, scopes and grantor.Every check takes a target. Safe with Octane and queued jobs. Owners get a full profile like anyone else.
check.php passes the target.PHP and 8 illuminate/* packages only. Every README example has a behaviour test.
Access Profiles works next to Laravel’s Gate. $user->can() and Gate keep their normal behaviour; your policies call canAccess().
A profile combines rules. A matching deny wins.
$profile = Access::profile('reviewer')->in($team)
->allow('billing.*')->deny('billing.invoice.approve')
->by($admin)->because('Keep approvals separate')->save();Assign the profile to a holder, with selected scopes, dates and a reason.
$assignment = Access::grant('reviewer')->to($user)->in($team)
->onlyScopes([$site, $otherSite])->titled('Review cover')
->validFrom(now())->validUntil(now()->addMonths(6))
->by($admin)->because('Cover leave')->save();Check access, or explain the decision.
$allowed = Access::for($user)->can('billing.invoice.view', $site);
$sameAnswer = $user->canAccess('billing.invoice.view', $site);
$decision = Access::for($user)->explain('billing.invoice.approve', $site);Query access with scope, validity, conditions and deny rules applied.
$holders = Access::holders('billing.invoice.view', $site);
$count = Access::holdersQuery('billing.invoice.view', $site)->count();
$history = Access::history($user, $team);Pass the permission and its target.
@canAccess('billing.invoice.view', $site)
View invoices
@endcanAccessAccess Profiles is new. Scope sets, deny rules and grant history are reasons to consider it. Other models may need less.
Checked on 2026-10-28. Scroll inside the table to compare packages; row labels stay visible.
| Capability | Access Profiles | spatie/laravel-permission | Bouncer | Laratrust | Gate + policies |
|---|---|---|---|---|---|
| Multi-tenant scope | Tenant + scopes inside it | One team dimension | One tenant id per request | One team, passed per call | You write it |
| One assignment → a set of scopes | Yes | — | — | — | You write it |
| Deny | Yes, deny wins | — | Yes, forbid() | — | You write it |
| Validity window | valid_from / valid_until | — | — | — | You write it |
| Grantor + reason audit | Yes, plus write events | — | — | — | You write it |
| “Who can do X here?” | Access::holders() | User::permission() for the current team | By role only | whereHasPermission() | Loop over users |
| Global tenant state | None; target per check | Global team id | Static scope | None | None |
| Owner / super-admin | No bypass | Gate::before | everything() | Role check | Gate::before |
| Laravel versions | 12.69+, 13.30+ | 12, 13 | 11, 12, 13 | 10, 11, 12, 13 | — |
| Maintenance | New | Active | Slow (about one release a year) | Active | Laravel core |
spatie/laravel-permission is the safe default for one tenant or one team level, with the biggest community. Bouncer is the closest in spirit (it has deny). Laratrust passes the team explicitly. Plain Gate + policies is enough when roles are few and fixed.
Full comparison with sourcesChoose table names before migrating.
PHP 8.3+
Laravel 12.69+ or 13.30+
Namespace: Seisigma\AccessProfiles
composer require seisigmasrl/laravel-access-profilesphp artisan vendor:publish --tag=access-profiles-configphp artisan vendor:publish --tag=access-profiles-migrationsConfigure the permission catalogue and templates. Choose table names before migrating.
php artisan migratephp artisan access-profiles:sync-templatesNo. Gate and $user->can() keep their normal behaviour. Call canAccess() or Access::for() from a policy.
No. An owner gets a full profile like everyone else.
There is no global tenant state. Every check takes a target. Jobs re-check when they run.
No. The cache stops at the next validity boundary.
No. Build your own screens.
Optional. With token_intersection, a token can only narrow access. No Sanctum dependency.
Yes. Write a BackfillSource and run access-profiles:backfill --dry-run first.
Allow and deny. Grant and revoke. Keep the reason.
composer require seisigmasrl/laravel-access-profiles