mpociot/teamwork
Teamwork is a Laravel package for adding teams, members, and user invitations to your app. Attach teams to any model with a simple trait, manage memberships and roles, and send email invitations with acceptance flows—without reinventing team management.
Install the package and publish its config/migrations:
composer require mpociot/teamwork
php artisan vendor:publish --provider="Mpociot\Teamwork\TeamworkServiceProvider" --tag=config
php artisan vendor:publish --provider="Mpociot\Teamwork\TeamworkServiceProvider" --tag=migrations
php artisan migrate
Check the published migration first — that's where the package's assumptions live. You should see teams, a team_user pivot (user_id, team_id, role), team_invitations, and a nullable current_team_id added to users. The current_team_id column is the hinge the whole package turns on; if it's missing, team switching silently won't work.
Wire your models to the traits and point config/teamwork.php at them:
// app/Models/User.php
use Mpociot\Teamwork\Traits\HasTeams;
class User extends Authenticatable
{
use HasTeams;
}
// app/Models/Team.php
use Mpociot\Teamwork\Traits\TeamTrait;
class Team extends Model
{
use TeamTrait;
}
// config/teamwork.php
'user_model' => App\Models\User::class,
'team_model' => App\Models\Team::class,
'invitation_model' => Mpociot\Teamwork\TeamInvitation::class,
First use case: onboarding. In your registration flow, create a team, attach the user as owner, and make it current:
return DB::transaction(function () use ($data) {
$user = User::create($data);
$team = Team::create([
'name' => $data['team_name'],
'owner_id' => $user->id,
]);
$user->attachTeam($team, 'owner'); // sets current_team_id if empty
return $user;
});
After that, $user->currentTeam, $user->teams, $team->users, and $team->owner give you the relationships you'll use everywhere else.
Scope the app by current team. The package stores current_team_id; it does not scope your queries. Do it explicitly in a middleware or a global scope:
public function handle($request, Closure $next)
{
$user = $request->user();
abort_unless($user && $user->currentTeam, 403, 'No current team.');
view()->share('currentTeam', $user->currentTeam);
return $next($request);
}
Then either Project::where('team_id', $user->current_team_id) per query, or a reusable BelongsToTeam trait with a global scope — the latter pays off quickly once you have several team-owned models.
Team switching. Never trust the route id without a membership check:
public function switch(Team $team)
{
abort_unless(auth()->user()->isTeamMember($team), 403);
auth()->user()->switchTeam($team);
return redirect()->route('dashboard');
}
Invitations. The facade handles creation and acceptance:
// invite to the current team (uses Auth::user()->currentTeam)
Teamwork::invite($request->email, 'member');
// accept, typically from an emailed link
Route::get('/invitations/{token}', function ($token) {
$invitation = Teamwork::getInvitationByToken($token);
abort_unless($invitation, 404);
Teamwork::acceptInvite($token);
return redirect()->route('dashboard');
})->middleware('auth')->name('teamwork.accept');
For users who don't have an account yet, stash the token in the session before redirecting to registration, then accept it after the account is created. That "invite → register → accept" flow is the one real workflow the package doesn't own for you.
Roles. The role column on the pivot is free-form; the package stores it but doesn't enforce it. Define constants and a small helper rather than reaching into the pivot everywhere:
public function roleIn(Team $team): ?string
{
return $this->teams()
->where('teams.id', $team->id)
->first()?->pivot->role;
}
Gate::define('manage-team', fn (User $user, Team $team) =>
$user->isTeamOwner($team) || $user->roleIn($team) === 'admin'
);
Member removal / ownership transfer. Removing a member is just detach, but you must repair current_team_id if it pointed at that team:
$team->users()->detach($user->id);
if ($user->current_team_id === $team->id) {
$next = $user->teams()->first();
$next ? $user->switchTeam($next) : $user->forceFill(['current_team_id' => null])->save();
}
Testing. Team state is plain Eloquent, so tests are cheap:
$user = User::factory()->create();
$team = Team::factory()->create(['owner_id' => $user->id]);
$user->attachTeam($team);
$this->actingAs($user)->get('/dashboard')->assertOk();
Prefer Notification::fake() / Mail::fake() around invite endpoints so you assert on the invitation row, not on SMTP.
current_team_id is not managed for you. It stays null for new users and can dangle after team deletion. Every authenticated page needs a "no current team" path: choose a default team, show an onboarding screen, or 403.attachTeam only switches if no current team exists. If a user is already on team A and you attach team B, their current team is still A. Use switchTeam explicitly when you mean it.abort_unless($invitation->email === auth()->user()->email, 403). Skipping this lets anyone with a link join any team.(team_id, user_id) and guard against re-inviting an existing member or creating a second pending invite for the same email/team. Default migrations are permissive.current_team_id, and deletes invitations — or rely on FK cascades if you configured them, because the package won't do it in PHP.Teamwork::invite() depends on auth state. Calling it from a queued job, console command, or after Auth::login() hasn't run will resolve the wrong (or no) current team. Bind the team/user explicitly in those contexts.config/teamwork.php stores class names; if you refactor namespaces, run php artisan config:clear. Also publish views (--tag=views) and the invite mail view if you want branded emails instead of editing vendor files.currentTeam and teams in your base controller or view composer; the nav bar and team switcher will otherwise N+1 on every request.illuminate/* constraint against your Laravel version before committing; modern typed properties, PHP 8.2 deprecations, and newer Eloquent conventions may need patches. With effectively no downstream dependents and an old release line, assume you own the maintenance. For a new app on Laravel 11+, a two-table hand-rolled pivot with a current_team_id column is often less work than forking this — the package's value is the invitation scaffolding, not the relationships.How can I help you explore Laravel packages today?