Permissions bridge
A membership carries one role. When you need more — several roles per member in a team, a role that lasts until Friday, a direct permission for one member, roles a team defines for itself — install permissions-for-laravel and switch the bridge on. Teams suggests the package, never requires it, and nothing changes until you opt in. team_members.role stays the membership role; the extra, fine-grained and time-boxed grants come from permissions-for-laravel, scoped to the team.
Turning it on
composer require roundly-consulting/permissions-for-laravel
php artisan vendor:publish --tag="permissions-migrations"
php artisan migratePERMISSIONS_SCOPE_KEY_TYPE=ulid # only when teams key differently from users — before migrating
PERMISSIONS_ASSIGNMENTS=true
TEAMS_PERMISSIONS_BRIDGE=trueIt needs permissions-for-laravel 2.1 or later — teams declares a conflict with anything older — with its scoped assignments on. Set its scope key type first if your teams key differently from your users. Then give your user model permissions’ HasRoles trait beside HasTeams:
use Illuminate\Foundation\Auth\User as Authenticatable;
use RoundlyConsulting\Permissions\Concerns\HasRoles;
use RoundlyConsulting\Teams\Traits\HasTeams;
class User extends Authenticatable
{
use HasRoles; // permissions-for-laravel
use HasTeams;
}With the switch on but the package missing, or with permissions.assignments.enabled off, the application fails at boot with an InvalidConfigurationException naming what is missing — a switch that is on and silently does nothing would be worse. php artisan about shows Permissions bridge ON or OFF.
Granting inside a team
members()->grant($member) is sugar over Permissions::for($member)->in($team) with two team rules:
use RoundlyConsulting\Teams\Facades\Teams;
Teams::for($team)->members()->grant($user)->assignRole('reviewer');
Teams::for($team)->members()->grant($user)->until($friday)->givePermissionTo('reports.export');
Teams::for($team)->members()->grant($user)->syncRoles(['reviewer', 'editor']); // the complete set in this team
Teams::for($team)->members()->grant($user)->removeRole('editor');
Teams::for($team)->members()->grant($user)->forgetAllAuthorization(); // everything in this team
Teams::for($team)->members()->grant($user)->hasPermissionTo('reports.export'); // bool
Teams::for($team)->members()->grant($user)->assignments(); // the member's rows in this team- Only members — an addition (assignRole, givePermissionTo, syncRoles, syncPermissions) needs a current or booked membership of this team; a non-member, a member of another team or an expired member throws MemberNotFoundException. Removals work for anyone, so clearing a leaver’s grants is always safe.
- The membership expiry is the default end — without until(), an addition ends when the membership expires. until($date) sets another end, until(null) none; from() and between() set the start.
- syncRoles() and syncPermissions() set the complete set in this team; removals clear every window.
- Names, backed enums, Role and Permission models and lists are all accepted — whatever permissions-for-laravel takes. Writing through Permissions::for($user)->in($team) directly works too: the bridge reads whatever is there, grant() only adds the membership rules and the Teams::fake() record.
grant() throws TeamsException while the bridge is off. Through an injected manager or the raw action, the same write looks like this:
use RoundlyConsulting\Teams\Bridges\Permissions\Actions\GrantMemberAuthorizationAction;
use RoundlyConsulting\Teams\Bridges\Permissions\DataTransferObjects\GrantMemberAuthorizationData;
use RoundlyConsulting\Teams\Bridges\Permissions\Enums\MemberGrant;
// Injected manager — the same API as the facade
$this->teams->for($team)->members()->grant($user)->assignRole('reviewer');
// The raw action
app(GrantMemberAuthorizationAction::class)->execute($team, new GrantMemberAuthorizationData(
member: $user,
verb: MemberGrant::AssignRole,
items: ['reviewer'],
));What the checks see
With the bridge on, memberHasPermission() and memberHasRole() answer true when the membership role grants it — as before — or when permissions-for-laravel grants it in the team. Everything built on them inherits the union: hasTeamPermission() and hasTeamRole(), the teams.<permission> Gate abilities, @teamPermission and @teamRole, and AbstractTeamPolicy:
$team->memberHasPermission($user, 'reports.export'); // the membership role OR a grant in the team
$user->can('teams.reports.export', $team); // the same, through the Gate
Route::middleware(['auth', 'teams.context', 'can:posts.edit'])
->put('/teams/{team}/posts/{post}', UpdatePost::class); // resolved in the current team- Checked at check time — an expired, not-yet-started or removed membership makes its team’s grants inert at once, before any cleanup runs.
- Names match — teams.posts.edit asks for the permission named posts.edit, and memberHasRole($u, 'reviewer') for the role named reviewer. The two catalogs are separate; register names where you grant them.
- Global grants — an unscoped permissions-for-laravel grant counts in every team the user is an active member of, while permissions.assignments.global_applies_in_scope is on (its default). Strict multi-tenant apps turn it off.
- Nested teams — with inherit_roles_down, a grant in a team above counts in the teams below it, for a member of that team above. A grant never travels up.
- A team grant never satisfies an unscoped check such as $user->can('posts.edit') without a team — except through the ambient scope below. Users without HasRoles simply get the membership-role answer, and teamsWithRole() and teamRole() keep reading the membership role only.
The current team as an ambient scope
The bridge registers Permissions::resolveScopeUsing(), so scope-less Gate checks — the can: middleware, @can('posts.edit'), $user->can('posts.edit') — resolve inside the current team (set by the teams.context middleware or Teams::switchTo()), plus the teams above it when roles inherit down, for active members only. No current team, multi-team mode or a non-member leaves the check unscoped. Your own resolveScopeUsing() in a service provider replaces it, because application providers boot after package providers.
Cleanup
- A member is removed (TeamMemberDeleted) or an expired membership is pruned (MembershipExpired) — the member’s grants in the team are forgotten.
- A lapsed membership is re-added — the same, since a revived membership starts afresh and its old grants must not return. Grants made before a brand-new membership are kept.
- A team is force-deleted — Permissions::forgetScope($team) removes its assignments and the roles it owns. A soft-deleted team keeps them, since it may be restored.
None of this is needed for correctness — the checks already ignore inactive memberships — it keeps the tables clean and stops a returning member from inheriting old grants.
Team-defined roles
For roles a team defines for itself, use permissions-for-laravel’s owned roles — inside the team’s scope the team’s own role wins over a global one of the same name. It is an alternative to per-team role overrides; the two are not merged:
use RoundlyConsulting\Permissions\Facades\Permissions;
Permissions::owner($team)->role('auditor')->givePermissionTo('reports.view');
Teams::for($team)->members()->grant($user)->assignRole('auditor'); // the team's own auditorTesting
Teams::fake() records every grant() write as GrantMemberAuthorization or RevokeMemberAuthorization, with the team, member, verb and names; Permissions::fake() sees the underlying scoped write too:
$fake = Teams::fake();
Teams::for($team)->members()->grant($user)->assignRole('reviewer');
$fake->assertMemberGranted($team, $user, 'reviewer');
$fake->assertNoGrantRevoked();
// Permissions::fake() sees the underlying scoped write as well:
// assertRoleAssigned($user, 'reviewer', scope: $team, until: $membershipExpiry)Without the bridge
Prefer to wire it yourself? The union is a few lines in a gate or policy of your own:
Gate::define('team-permission', fn (User $user, Team $team, string $permission): bool =>
$team->memberHasPermission($user, $permission)
|| ($team->hasActiveMember($user) && Permissions::for($user)->in($team)->hasPermissionTo($permission)));Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.