Apps (Coming Soon) 27 / 32
Teams
Drop-in team support for Laravel — teams, memberships, roles and permissions, and email invitations — with a Livewire / Volt + Tailwind UI and a data model that mirrors the Laravel starter kits.
devdojo/teams adds multi-tenant team support to a Laravel app: teams owned by a user, members joined through a pivot that carries a role, configurable roles and permissions, and email invitations with signed accept links. The data model and API deliberately mirror the team functionality in the official Laravel starter kits (Jetstream), so it feels immediately familiar. It ships as a feature package of foundation but also works standalone.
Overview
The shape is small and predictable:
- A Team is owned by exactly one user (
teams.user_id) and has many members through theteam_userpivot. - Each membership carries a role key (
admin,editor,member, … — fully configurable). The owner implicitly holds every permission. - A user's current team is stored on
users.current_team_id;switchTeam()changes it. - Members are added directly, or invited by email with a signed accept link.
Everything a user can do is exposed through the HasTeams trait, single-purpose action classes, a set of events, and a TeamPolicy — so you can drive teams from your own controllers, jobs, and Blade @can checks. Alongside the API, the package ships a complete Livewire/Volt UI you can either use as-is or embed piece by piece.
Requirements
- PHP 8.2+ and Laravel 11, 12, or 13.
- Livewire 3/4, Volt, and Folio are pulled in automatically by Composer — no Spatie or other role packages required; roles are config-driven value objects.
- A working mailer if you use email invitations (the default); with invitations disabled, no mail is sent.
Installation
composer require devdojo/teams
Publish the config and migrations, then migrate:
php artisan vendor:publish --tag=teams:config
php artisan vendor:publish --tag=teams:migrations
php artisan migrate
This creates the teams, team_user, and team_invitations tables and adds a nullable current_team_id column to users. Migrations are publish-only, so they live in your app and are yours to edit. If your User isn't discoverable from config('auth.providers.users.model'), set TEAMS_USER_MODEL.
Wiring your User model
Add the HasTeams trait — that's the only required integration step:
use Devdojo\Teams\Traits\HasTeams;
class User extends Authenticatable
{
use HasTeams;
}
Personal teams
By default, every newly registered user is given a personal team (named from their first name — "Tony's Team"), which becomes their current team. This is handled automatically by a listener on Laravel's Registered event — no code required as long as your User uses HasTeams. Personal teams cannot be deleted: the DeleteTeam action rejects them with a validation error. Turn the behavior off in config/teams.php under features.personal_teams, or create teams yourself:
use Devdojo\Teams\Actions\CreateTeam;
$team = app(CreateTeam::class)->create($user, ['name' => 'Acme']);
The data model
| Migration | Table | Key columns |
|---|---|---|
create_teams_table |
teams |
user_id (owner, indexed), name, personal_team (bool) |
create_team_user_table |
team_user |
team_id, user_id, role (nullable); unique on (team_id, user_id) |
create_team_invitations_table |
team_invitations |
team_id (cascade FK), email, role (nullable); unique on (team_id, email) |
add_current_team_id_to_users_table |
users |
current_team_id (nullable) |
The team_user pivot is represented by the Membership model, so a member's role reads as $member->membership->role.
The bundled UI
Two Folio pages are registered out of the box, behind your configured middleware (default web, auth):
| URL | Route name | Purpose |
|---|---|---|
/teams/create |
teams.create |
Create a new team |
/teams/{team} |
teams.show |
Team settings: name, members, invitations, delete |
These render inside a self-contained layout (x-teams::layouts.app) so they work with zero setup. For production, the recommended approach is to embed the individual Livewire/Volt components into your own layout:
{{-- In your app chrome --}}
@auth
<livewire:teams.team-switcher />
@endauth
{{-- On your own settings page --}}
<livewire:teams.update-team-name-form :team="$team" />
<livewire:teams.team-member-manager :team="$team" />
<livewire:teams.delete-team-form :team="$team" />
| Component | What it does |
|---|---|
teams.team-switcher |
Current-team dropdown + switch / create / settings links |
teams.create-team-form |
Create a team and switch onto it |
teams.update-team-name-form |
Rename the team (owner / update permission) |
teams.team-member-manager |
Invite/add members, manage roles, cancel invites, remove / leave |
teams.delete-team-form |
Delete a non-personal team (owner only) |
Tailwind must scan the package. If the bundled pages look unstyled, Tailwind isn't generating the package's utility classes. In Tailwind v4 add @source "../../vendor/devdojo/teams/resources/**/*.blade.php"; to your app.css and rebuild — or publish the views with teams:views and own them. Without a Vite build, the standalone pages fall back to the Tailwind CDN so they still render.
Working with teams in code
The HasTeams trait gives your User:
$user->currentTeam; // BelongsTo — the user's current team
$user->currentTeamOrDefault(); // current, falling back to personal/first
$user->ownedTeams; // HasMany — teams the user owns
$user->teams; // BelongsToMany — teams they belong to
$user->allTeams(); // owned + member teams
$user->ownsTeam($team); // bool
$user->belongsToTeam($team); // bool — owner or member
$user->teamRole($team); // ?Role — OwnerRole for the owner
$user->hasTeamRole($team, 'admin'); // bool
$user->teamPermissions($team); // array<string>
$user->hasTeamPermission($team, 'update'); // bool
$user->switchTeam($team); // bool — sets current_team_id (false if not a member)
On a Team:
$team->owner; // BelongsTo User
$team->users; // BelongsToMany members
$team->allUsers(); // owner + members
$team->teamInvitations; // HasMany pending invitations
$team->hasUser($user); // bool
$team->hasUserWithEmail($email); // bool
$team->removeUser($user); // detach + reset their current team if needed
$team->purge(); // delete members, invitations, and the team
Roles and permissions
Roles are defined in config/teams.php. The first role listed is the default for new members; the owner always has every permission. Check permissions anywhere to authorize your own team-scoped resources:
'roles' => [
'admin' => ['name' => 'Administrator', 'permissions' => ['create', 'read', 'update', 'delete']],
'editor' => ['name' => 'Editor', 'permissions' => ['read', 'create', 'update']],
'member' => ['name' => 'Member', 'permissions' => ['read']],
],
if ($user->hasTeamPermission($user->currentTeam, 'update')) {
// allow editing a team-owned resource
}
You can also register or override roles at runtime (e.g. in a service provider) through the Teams facade:
use Teams; // alias for Devdojo\Teams\Teams
Teams::role('billing', 'Billing Manager', ['read', 'update'], 'Manages the team subscription.');
Inviting members
With features.invitations enabled (the default), adding a member creates a team_invitations row and emails a signed accept link:
| Method | URI | Route name | Middleware |
|---|---|---|---|
GET |
/team-invitations/{invitation}/accept |
teams.invitations.accept |
web, auth, signed |
When the recipient clicks the link (logged in with the matching email), they're added to the team and switched onto it. The invitation email is a Markdown mailable (TeamInvitationMail), so a working mailer is required. With invitations disabled, the member manager instead adds existing registered users directly by email — no mail is sent.
Actions
Every write operation is a single-purpose, injectable action class. Each one validates and authorizes, then fires the relevant event — call them from your own controllers, jobs, or commands:
app(CreateTeam::class)->create($user, ['name' => 'Acme']);
app(UpdateTeamName::class)->update($user, $team, ['name' => 'Acme Inc.']);
app(AddTeamMember::class)->add($user, $team, '[email protected]', 'editor');
app(InviteTeamMember::class)->invite($user, $team, '[email protected]', 'editor');
app(UpdateTeamMemberRole::class)->update($user, $team, $memberId, 'admin');
app(RemoveTeamMember::class)->remove($user, $team, $member);
app(DeleteTeam::class)->delete($user, $team);
Events
| Event | Dispatched when |
|---|---|
TeamCreated |
a team is created |
TeamUpdated |
a team's name is updated |
TeamDeleted |
a team is deleted |
TeamMemberAdded |
a user is added / accepts an invite |
TeamMemberInvited |
a user is invited by email |
TeamMemberRemoved |
a member is removed or leaves |
use Devdojo\Teams\Events\TeamMemberAdded;
Event::listen(TeamMemberAdded::class, function (TeamMemberAdded $event) {
// $event->team, $event->user
});
Authorization — the Team policy
A TeamPolicy is registered automatically, so $user->can('update', $team) and Blade @can work everywhere:
| Ability | Default rule |
|---|---|
view |
member or owner |
create |
any authenticated user |
update |
owner or update permission |
addTeamMember |
owner or create permission |
updateTeamMember |
owner or update permission |
removeTeamMember |
owner or delete permission |
delete |
owner only |
The policy is registered against the configured team model, so point teams.models.team at your own Team subclass to customize behavior, or re-register the abilities with your own policy class in a service provider.
How it's wired
TeamsServiceProvider merges config/teams.php, registers the HasTeams trait's relationships, the TeamPolicy, the Folio pages, Volt components, the Registered listener that creates personal teams, and the Teams facade. Like every foundation package it self-gates on its feature flag:
// config/foundation.php
'features' => ['teams' => true],
'depends' => ['teams' => ['auth']], // enabling teams ensures auth is enabled too
When teams is disabled, the routes, Folio pages, Volt components, policy, and personal-team listener aren't registered — but the models, trait, and migrations remain, so toggling is lossless. Standalone (no Foundation present) the flag is absent and teams defaults to on. Because teams depends on auth, enabling it ensures auth is on too.
Configuration reference
| Key | Default | Purpose |
|---|---|---|
user_model |
env('TEAMS_USER_MODEL') |
Host User model (null → auth.providers.users.model) |
models.team |
Devdojo\Teams\Models\Team |
Swap for a subclass to extend |
models.membership |
Devdojo\Teams\Models\Membership |
The team_user pivot model |
models.team_invitation |
Devdojo\Teams\Models\TeamInvitation |
Invitation model |
features.personal_teams |
true |
Auto-create a personal team on registration |
features.invitations |
true |
Invite by email vs. add existing users directly |
middleware |
['web', 'auth'] |
Middleware for the bundled pages |
prefix |
teams |
Reserved for future use — the bundled Folio pages currently live at fixed /teams/... paths |
redirect_after_switch |
/teams/{team} |
Redirect after switching/joining ({team} → id) |
redirect_after_create |
/teams/{team} |
Redirect after creating a team |
roles |
admin / editor / member | Available roles and their permissions |
| Publish tag | Publishes to |
|---|---|
teams:config |
config/teams.php |
teams:migrations |
database/migrations |
teams:views |
resources/views/vendor/teams |
Team membership pairs naturally with the rest of the Foundation — gate app entitlements with billing feature limits and surface invitation activity through notifications. When a platform screen needs richer team behavior, add it here so every app on the package benefits.