Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Teamwork Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

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.

Gotchas and Tips

  • 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.
  • Enforce the invite email matches the logged-in user. The package resolves an invitation by token and accepts it; it's on you to abort_unless($invitation->email === auth()->user()->email, 403). Skipping this lets anyone with a link join any team.
  • Dedupe invitations and memberships. Add a unique index on (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.
  • Accept links are state-changing GETs. Consider wrapping acceptance in a signed URL or a POST-confirm page, especially if email scanners hit your links.
  • Cleanup on team deletion. Add an observer that detaches users, nulls out 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 caching bites model strings. 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.
  • Eager-load currentTeam and teams in your base controller or view composer; the nav bar and team switcher will otherwise N+1 on every request.
  • It's a Laravel 5-era package. Check the 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core