The Teams facade
Teams is the entry point for everything the package does. Team-scoped work hangs off Teams::for($team), which returns a handle with one sub-accessor per area; cross-team work — accepting an invite by code, housekeeping, the role and permission vocabulary — sits on the facade itself, and so does the current team context:
use RoundlyConsulting\Teams\DataTransferObjects\CreateTeamData;
use RoundlyConsulting\Teams\Facades\Teams;
// Create a team; the owner joins with the configured owner role.
$team = Teams::create(new CreateTeamData(name: 'Acme', owner: $owner));
// Members
Teams::for($team)->members()->add($alice, 'admin', expiresAt: now()->addYear());
Teams::for($team)->members()->add($bob, 'member', startsAt: $monday); // with teams.members.start_dates
Teams::for($team)->members()->changeRole($alice, 'editor'); // throws MemberNotFoundException for a non-member
Teams::for($team)->members()->remove($bob); // bool
Teams::for($team)->members()->all(); // Collection<Member>
Teams::for($team)->members()->hasActive($alice); // bool — started, not expired
Teams::for($team)->members()->find($alice); // ?Member
Teams::for($team)->members()->grant($alice)->assignRole('reviewer'); // with the permissions bridge
// Invites
$invite = Teams::for($team)->invites()->create(role: 'member', email: '[email protected]', maxUses: 1);
Teams::for($team)->invites()->resend($invite); // rotate code, extend expiry
Teams::for($team)->invites()->revoke($invite); // bool
Teams::for($team)->invites()->pending(); // Collection<Invite>
Teams::invites()->accept($invite, $user, email: $user->email); // email must match an email-targeted invite
Teams::invites()->accept('the-code', $user); // a link invite (no email) by its code
// Join requests
$request = Teams::for($team)->joinRequests()->open($user, requestedRole: 'member', message: 'Hi');
Teams::for($team)->joinRequests()->approve($request, by: $admin, role: 'member');
Teams::for($team)->joinRequests()->deny($request, by: $admin);
Teams::for($team)->joinRequests()->pending(); // Collection<JoinRequest>
// Per-team roles, ownership, settings and the companion packages
Teams::for($team)->roles()->define('editor', 'Editor', ['posts.edit']);
Teams::for($team)->roles()->all(); // effective role map
Teams::for($team)->transferOwnershipTo($alice); // Team
Teams::for($team)->settings(); // TeamSettings (options package)
Teams::for($team)->contacts(); // ContactBook (contacts package)
Teams::for($team)->addresses(); // AddressBook (addresses package)
Teams::for($team)->connections(); // PendingConnection (connections package)
Teams::for($team)->team(); // the scoped Team model
Teams::for($team)->moveUnder($company); // Team — with teams.nesting.enabled
Teams::for($team)->run(fn () => Project::count()); // run inside the team's context
// The current team
Teams::switchTo($team); // checked against the signed-in user
Teams::setCurrent($team); // unchecked — system code only
Teams::current(); // ?Team
Teams::runAs($team, fn () => Report::generate()); // restores the previous context afterwards
Teams::withoutTeamScope(fn () => Project::count()); // lift isolation for one callback
Teams::scopedModels(); // list<ScopedModelReport> — what teams:doctor checks
// Global vocabulary and housekeeping
Teams::roles()->register('admin', 'Admin', ['*']);
Teams::permissions()->register('posts.publish', 'Publish posts', group: 'Content');
Teams::invites()->prune(); // int
Teams::members()->expiring(days: 7); // Collection<Member>, read-only
Teams::members()->notifyExpiring(days: 7); // fires MembershipExpiringSoon
Teams::members()->prune(); // int
Teams::joinRequests()->expire(); // int — auto-decline expired pending requestsFacade methods
| Method | Returns | Notes |
|---|---|---|
create(CreateTeamData $data) | Team | Seats the owner with roles.owner. |
for(Team $team) | TeamHandle | Team-scoped handle — see the next table. |
invites() | Invites | accept(), find(), prune() |
members() | Members | expiring(), notifyExpiring(), prune() |
joinRequests() | JoinRequests | expire() |
roles() | RoleProvider | The configured role driver: register(), find(), all(). |
permissions() | PermissionRegistry | register(), group(), find(), all(), fromRoles() |
switchTo(Team $team, ?Model $user = null) | void | Checked: set the current team only for an active member, else TeamContextException. |
setCurrent(?Team $team) | void | Unchecked — system code only; null clears the context. |
setCurrentMany(iterable $teams, ?Model $user = null) | void | Multi-team mode; every team is checked, all or nothing. |
current() / currentMany() | ?Team / Collection<Team> | The team(s) in context. |
runAs(Team $team, Closure $callback) | mixed | Run in a team’s context and restore the previous one. |
withoutTeamScope(Closure $callback) | mixed | Lift team isolation for one callback, this process only. |
scopedModels() / teamKeys() | list<ScopedModelReport> / TeamKeyReport | What teams:doctor checks — see Data isolation. |
fake() | TeamsFake | Recording fake — see Testing. |
The team handle
Teams::for($team) returns a TeamHandle. Every write returns its result — a Member, an Invite, a bool — not the handle, so there is no chaining:
| Teams::for($team)->… | Returns | Notes |
|---|---|---|
members()->add($member, $role, $meta = [], $expiresAt = null, $startsAt = null) | Member | Idempotent; revives a removed or expired row; respects the seat cap. startsAt needs teams.members.start_dates. |
members()->remove($member) | bool | Soft-deletes; false for a non-member. |
members()->changeRole($member, $role) | Member | Throws MemberNotFoundException for a non-member. |
members()->all() / hasActive($m) / find($m) | Collection / bool / ?Member | all() includes expired memberships; hasActive() is true only for a started, unexpired one. has($m) still works but is deprecated — it matches any row. |
members()->grant($member) | MemberGrants | The member’s permissions-for-laravel grants in this team — see Permissions bridge. |
invites()->create($role, $expiresAt, $email, $invitedBy, $meta, $maxUses = 1) | Invite | See Invitations. |
invites()->resend($invite) / revoke($invite) | Invite / bool | Refuses another team’s invite. |
invites()->pending() | Collection<Invite> | The team’s unexpired invites. |
joinRequests()->open($requester, $requestedRole, $message, $meta, $expiresAt) | JoinRequest | Honours the join policy; refuses active members; idempotent while pending. |
joinRequests()->approve($request, by:, role:) / deny($request, by:) | Member / JoinRequest | Refuses another team’s request and one already resolved. |
joinRequests()->requireApprovalFrom() / rule() / quorum() | new handle | Stage multi-admin sign-off for the next open(). |
joinRequests()->pending() | Collection<JoinRequest> | |
roles()->define($key, $name, $permissions = [], $description = '') | TeamRole | Per-team override, upserted on (team, key). |
roles()->all() / find($key) | array / ?Role | Global roles merged with this team’s overrides. |
transferOwnershipTo($owner) | Team | All-or-nothing; demotes the previous owner to roles.admin. |
settings() | TeamSettings | See Team settings. |
contacts() / addresses() / connections() | ContactBook / AddressBook / PendingConnection | See Integrations. |
moveUnder($parent) / moveToRoot() | Team | See Nested teams. |
run(Closure $callback) | mixed | Teams::runAs() for this team. |
team() | Team | The scoped model. |
Scoping is a security boundary
A handle from Teams::for($teamA) refuses rows that belong to another team, before anything runs. So a controller can take the team from the route and the invite or request from user input without an extra ownership check:
| Call with another team’s row | Throws |
|---|---|
invites()->resend($invite) / revoke($invite) | InviteNotFoundException |
joinRequests()->approve($request, …) / deny($request, …) | JoinRequestNotFoundException |
members()->changeRole($nonMember, …) | MemberNotFoundException |
use RoundlyConsulting\Teams\Exceptions\InviteNotFoundException;
use RoundlyConsulting\Teams\Facades\Teams;
// The team comes from the route, the invite from user input — no extra ownership check.
try {
Teams::for($team)->invites()->revoke($invite);
} catch (InviteNotFoundException) {
// "Invite #42 does not belong to this team." — nothing ran, nothing was recorded
}Model shorthand
The model convenience methods delegate to the same manager, so they behave — and are recorded by Teams::fake() — exactly like the handle calls:
| Model method | Same as |
|---|---|
$team->addMember($m, $role, $meta, $expiresAt, $startsAt) | Teams::for($team)->members()->add(…) |
$team->removeMember($m) | Teams::for($team)->members()->remove($m) |
$team->invite($role, $expiresAt, $email, $invitedBy, $meta, $maxUses) | Teams::for($team)->invites()->create(…) |
$team->defineRole($key, $name, $permissions, $description) | Teams::for($team)->roles()->define(…) |
$team->roles() | Teams::for($team)->roles()->all() |
$invite->acceptBy($member, $email) | Teams::invites()->accept($invite, $member, $email) |
$invite->resend() / revoke() | Teams::for($invite->team)->invites()->resend/revoke($invite) |
$membership->removeFromTeam() | Teams::for($membership->team)->members()->remove(…) |
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.