---
title: "Permissions bridge — Teams for Laravel | Roundly"
description: "Optional bridge to permissions-for-laravel: several roles per member, time-boxed and direct grants inside a team, team-defined roles and cleanup."
url: https://roundly-consulting.com/open-source/docs/teams-for-laravel/permissions-bridge
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Teams for Laravel](https://roundly-consulting.com/open-source/docs/teams-for-laravel.md)

# 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](https://roundly-consulting.com/open-source/docs/permissions-for-laravel.md) 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

```bash
composer require roundly-consulting/permissions-for-laravel
php artisan vendor:publish --tag="permissions-migrations"
php artisan migrate
```

```bash
PERMISSIONS_SCOPE_KEY_TYPE=ulid   # only when teams key differently from users — before migrating
PERMISSIONS_ASSIGNMENTS=true
TEAMS_PERMISSIONS_BRIDGE=true
```

It needs permissions-for-laravel 2.1 or later — teams declares a conflict with anything older — with its [scoped assignments](https://roundly-consulting.com/open-source/docs/permissions-for-laravel/scoped-grants.md) 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:

```php
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:

```php
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:

```php
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:

```php
$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](https://roundly-consulting.com/open-source/docs/permissions-for-laravel/owned-roles.md) — 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:

```php
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 auditor
```

## Testing

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:

```php
$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:

```php
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 crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/teams-for-laravel.md)
