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

Add, remove and inspect members through the team handle — or the Team model shorthand, which runs the same manager call. Roles are plain string keys:

use RoundlyConsulting\Teams\Facades\Teams;

$members = Teams::for($team)->members();

$member  = $members->add($user, 'member');                          // Member
$member  = $members->add($user, 'editor', meta: ['source' => 'import']);
$removed = $members->remove($user);   // bool — soft-deletes; false when not a member

$members->find($user);        // ?Member
$members->hasActive($user);   // bool — a member right now: started, not expired
$members->all();              // Collection<Member> — expired memberships included

$team->memberHasRole($user, 'editor');             // bool
$team->memberHasPermission($user, 'posts.edit');   // bool

// Model shorthand — the same manager call:
$team->addMember($user, 'member');
$team->removeMember($user);

Idempotent adds

A team holds one membership row per member — a unique index that covers soft-deleted rows too. Adding is idempotent: re-adding an active member returns the existing membership and updates the role only when it differs — no duplicate rows, no defensive checks in your code. An expiresAt is overwritten only when you pass one, and a membership’s original accepted invite is never rewritten.

Re-adding a member who was removed, or whose membership expired, revives that row as a fresh membership — role, meta and expiry come from the new call (no expiresAt means permanent) — and fires TeamMemberAdded. It takes a seat, so MaxSeats applies:

$members = Teams::for($team)->members();

$members->add($user, 'member');   // creates the membership — fires TeamMemberAdded
$members->add($user, 'member');   // no-op — returns the same Member
$members->add($user, 'admin');    // updates the role — fires TeamMemberRoleChanged

$members->remove($user);          // soft-deletes the row
$members->add($user, 'member');   // revives that row as a fresh membership — fires TeamMemberAdded

Adds run in a transaction that locks the team row, so simultaneous adds — a double submit, two people accepting the last seat — serialise: one row per member, and the seat cap holds.

Changing roles and removing

Teams::for($team)->members()->changeRole($bob, 'admin');   // Member — throws MemberNotFoundException for a non-member
Teams::for($team)->members()->remove($bob);                // bool — soft-deletes the membership

$team->findMember($bob)?->removeFromTeam();                // bool — the same, from the Member side

A role change fires TeamMemberRoleChanged (with the previous role) only when the role actually changes; changing the role of a model that isn’t a member throws MemberNotFoundException. Removal fires TeamMemberDeleted and is a soft delete — the row stays for audits, and adding the member again revives it.

Is someone a member right now?

A removed membership is soft-deleted, but an expired one keeps its row for audits until pruned — so check membership with the active variants, which ignore expired memberships and, with start dates on, ones that have not started yet:

$team->hasActiveMember($user);                   // a member right now — started, not expired
Teams::for($team)->members()->hasActive($user); // the same, through the facade
$user->isActiveMemberOf($team);                 // the same, from the member side
$team->isAccessibleBy($user);                   // may act in the team — counts a team above with inherit_roles_down

$team->hasMember($user);                        // deprecated — a row exists, an expired one included

Team::hasMember() and members()->has() are deprecated: they only check that a row exists, so an expired membership still counts. The next major makes them — and belongsToTeam() and isMemberOf() — ignore expired memberships too.

The Member model

$member = $team->findMember($user);

$member->role;             // 'admin' — the stored role key
$member->role();           // ?Role — resolved (per-team aware); null once expired or before it starts
$member->member;           // the member model (MorphTo)
$member->team;             // the Team
$member->meta;             // Collection
$member->starts_at;        // ?Carbon — read with teams.members.start_dates
$member->expires_at;       // ?Carbon
$member->isExpired();      // bool
$member->hasStarted();     // bool — always true while start dates are off
$member->isActive();       // bool — started and not expired
$member->acceptedInvite;   // the Invite this member joined through, or null

Seat limits

When a team has a MaxSeats setting, adding a new member — or reviving a removed or expired one — throws TeamsException once the active membership count reaches the cap. Re-adding an active member never trips it — see Team settings.

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.