NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Teams for Laravel

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 requests

Facade methods

MethodReturnsNotes
create(CreateTeamData $data)TeamSeats the owner with roles.owner.
for(Team $team)TeamHandleTeam-scoped handle — see the next table.
invites()Invitesaccept(), find(), prune()
members()Membersexpiring(), notifyExpiring(), prune()
joinRequests()JoinRequestsexpire()
roles()RoleProviderThe configured role driver: register(), find(), all().
permissions()PermissionRegistryregister(), group(), find(), all(), fromRoles()
switchTo(Team $team, ?Model $user = null)voidChecked: set the current team only for an active member, else TeamContextException.
setCurrent(?Team $team)voidUnchecked — system code only; null clears the context.
setCurrentMany(iterable $teams, ?Model $user = null)voidMulti-team mode; every team is checked, all or nothing.
current() / currentMany()?Team / Collection<Team>The team(s) in context.
runAs(Team $team, Closure $callback)mixedRun in a team’s context and restore the previous one.
withoutTeamScope(Closure $callback)mixedLift team isolation for one callback, this process only.
scopedModels() / teamKeys()list<ScopedModelReport> / TeamKeyReportWhat teams:doctor checks — see Data isolation.
fake()TeamsFakeRecording 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)->…ReturnsNotes
members()->add($member, $role, $meta = [], $expiresAt = null, $startsAt = null)MemberIdempotent; revives a removed or expired row; respects the seat cap. startsAt needs teams.members.start_dates.
members()->remove($member)boolSoft-deletes; false for a non-member.
members()->changeRole($member, $role)MemberThrows MemberNotFoundException for a non-member.
members()->all() / hasActive($m) / find($m)Collection / bool / ?Memberall() 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)MemberGrantsThe member’s permissions-for-laravel grants in this team — see Permissions bridge.
invites()->create($role, $expiresAt, $email, $invitedBy, $meta, $maxUses = 1)InviteSee Invitations.
invites()->resend($invite) / revoke($invite)Invite / boolRefuses another team’s invite.
invites()->pending()Collection<Invite>The team’s unexpired invites.
joinRequests()->open($requester, $requestedRole, $message, $meta, $expiresAt)JoinRequestHonours the join policy; refuses active members; idempotent while pending.
joinRequests()->approve($request, by:, role:) / deny($request, by:)Member / JoinRequestRefuses another team’s request and one already resolved.
joinRequests()->requireApprovalFrom() / rule() / quorum()new handleStage multi-admin sign-off for the next open().
joinRequests()->pending()Collection<JoinRequest>
roles()->define($key, $name, $permissions = [], $description = '')TeamRolePer-team override, upserted on (team, key).
roles()->all() / find($key)array / ?RoleGlobal roles merged with this team’s overrides.
transferOwnershipTo($owner)TeamAll-or-nothing; demotes the previous owner to roles.admin.
settings()TeamSettingsSee Team settings.
contacts() / addresses() / connections()ContactBook / AddressBook / PendingConnectionSee Integrations.
moveUnder($parent) / moveToRoot()TeamSee Nested teams.
run(Closure $callback)mixedTeams::runAs() for this team.
team()TeamThe 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 rowThrows
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 methodSame 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 crypto

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