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:

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 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()
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)MemberIdempotent; revives a removed or expired row; respects the seat cap.
members()->remove($member)boolSoft-deletes; false for a non-member.
members()->changeRole($member, $role)MemberThrows MemberNotFoundException for a non-member.
members()->all() / has($m) / find($m)Collection / bool / ?Memberall() includes expired memberships.
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.
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)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.