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:
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()->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()->has($alice); // bool
Teams::for($team)->members()->find($alice); // ?Member
// 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
// 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() |
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) | Member | Idempotent; revives a removed or expired row; respects the seat cap. |
members()->remove($member) | bool | Soft-deletes; false for a non-member. |
members()->changeRole($member, $role) | Member | Throws MemberNotFoundException for a non-member. |
members()->all() / has($m) / find($m) | Collection / bool / ?Member | all() includes expired memberships. |
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. |
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) | 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.