Drop-in team support for Laravel — teams, memberships, roles & permissions, and email invitations — with a polished, ready-to-use Livewire / Volt + Tailwind UI. The data model and API mirror the team functionality in the official Laravel starter kits (Jetstream), so it will feel immediately familiar.
devdojo/teams is one of the feature packages bundled by
devdojo/foundation, but it works perfectly well
standalone in any Laravel app.
- Requirements
- How it works
- Installation
- Wiring your User model
- Personal teams
- The data model
- The bundled UI
- Working with teams in code
- Roles & permissions
- Inviting members
- Switching teams
- Actions
- Events
- Authorization (the Team policy)
- Using with DevDojo Foundation
- Configuration reference
- FAQ / troubleshooting
- License
| Requirement | Notes |
|---|---|
PHP ^8.2 |
|
Laravel ^11 / ^12 / ^13 |
|
Livewire ^3 / ^4 + Volt ^1 |
Powers the bundled UI components & pages. |
Laravel Folio ^1 |
Routes the bundled /teams/* pages. |
The UI uses Tailwind CSS utility classes. A working mailer is needed only if you use email invitations.
┌──────────────────────────────────────────────────────────────────────┐
│ devdojo/teams │
│ │
│ User ──owns──▶ Team ──hasMany──▶ TeamInvitation │
│ │ ▲ │
│ └──belongsTo──┘ (team_user pivot = Membership, carries `role`) │
│ │
│ Trait added to your User: │
│ • HasTeams (teams, ownedTeams, switchTeam, hasTeamPermission …) │
│ │
│ UI: /teams/create • /teams/{team} • <livewire:teams.* /> │
│ Roles & permissions • email invitations • Team policy │
└──────────────────────────────────────────────────────────────────────┘
- A Team is owned by one user (
teams.user_id) and has many members through theteam_userpivot. - Each membership carries a role (
admin,editor,member, … — fully configurable). The owner implicitly has every permission. - A user's current team is stored on
users.current_team_id;switchTeam()changes it. - Members can be added directly or invited by email with a signed accept link.
composer require devdojo/teamsPublish the config and migrations, then migrate:
php artisan vendor:publish --tag=teams:config
php artisan vendor:publish --tag=teams:migrations
php artisan migrateThis creates the teams, team_user, and team_invitations tables and adds a nullable
current_team_id column to your users table.
Migrations are publish-only (not auto-loaded) so the tables live in your app's
database/migrationsand are yours to edit.
If your User model can't be discovered from config('auth.providers.users.model'), set it
explicitly:
TEAMS_USER_MODEL="App\\Models\\User"Then wire your User model.
Add the HasTeams trait to your User model:
use Devdojo\Teams\Traits\HasTeams;
class User extends Authenticatable
{
use HasTeams;
}That's the only required integration step. The trait adds the relationships and helper methods described in Working with teams in code.
By default every newly registered user is given a personal team, which becomes their
current team. This is handled automatically by listening for Laravel's Registered event — no
code required, as long as your User uses the HasTeams trait.
Turn it off in config/teams.php:
'features' => [
'personal_teams' => false,
],You can also create a team yourself at any time:
use Devdojo\Teams\Actions\CreateTeam;
$team = app(CreateTeam::class)->create($user, ['name' => 'Acme']);| Column | Type | Notes |
|---|---|---|
id |
id | |
user_id |
foreignId | the owner (indexed) |
name |
string | |
personal_team |
boolean | personal teams cannot be deleted |
| timestamps |
| Column | Type | Notes |
|---|---|---|
id |
id | |
team_id |
foreignId | |
user_id |
foreignId | |
role |
string, nullable | role key, e.g. admin |
| timestamps | unique on (team_id, user_id) |
| Column | Type | Notes |
|---|---|---|
id |
id | |
team_id |
foreignId | FK → teams (cascade) |
email |
string | invited address |
role |
string, nullable | role to grant on accept |
| timestamps | unique on (team_id, email) |
| Column | Type | Notes |
|---|---|---|
current_team_id |
unsigned big int, nullable | the team the user is viewing |
Two Folio pages are registered out of the box (behind your configured middleware, default
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. To match your own app chrome instead, drop the embeddable components
into your own pages — that's the recommended approach for production.
The switcher lists the user's teams, shows the current one, and links to create/manage:
@auth
<livewire:teams.team-switcher />
@endauthEvery piece of the management screen is an independent Livewire/Volt component you can place in your own Blade layout:
{{-- Your own settings page, using your own layout --}}
<livewire:teams.update-team-name-form :team="$team" />
<livewire:teams.team-member-manager :team="$team" />
<livewire:teams.delete-team-form :team="$team" />
{{-- Standalone --}}
<livewire:teams.create-team-form />| 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) |
The bundled views use Tailwind utility classes. When a Vite build exists, the standalone pages use your compiled CSS; otherwise they fall back to the Tailwind CDN so they still render.
For production, tell Tailwind to scan the package so its classes are generated. In Tailwind v4,
add a @source line to your resources/css/app.css:
@import "tailwindcss";
/* installed via Composer */
@source "../../vendor/devdojo/teams/resources/**/*.blade.php";Or publish the views and own them entirely:
php artisan vendor:publish --tag=teams:views # → resources/views/vendor/teamsThe HasTeams trait gives your User model:
$user->currentTeam; // BelongsTo — the user's current team
$user->currentTeamOrDefault(); // current team, falling back to personal/first
$user->ownedTeams; // HasMany — teams the user owns
$user->teams; // BelongsToMany — teams they belong to
$user->allTeams(); // Collection — owned + member teams
$user->personalTeam(); // ?Team
$user->ownsTeam($team); // bool
$user->belongsToTeam($team); // bool — owner or member
$user->isCurrentTeam($team); // bool
$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_idOn a Team:
$team->owner; // BelongsTo User
$team->users; // BelongsToMany members (pivot: $member->membership->role)
$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 teamRoles are defined in config/teams.php. The first role listed is the default for new
members; the team owner always has every permission.
'roles' => [
'admin' => ['name' => 'Administrator', 'permissions' => ['create', 'read', 'update', 'delete']],
'editor' => ['name' => 'Editor', 'permissions' => ['read', 'create', 'update']],
'member' => ['name' => 'Member', 'permissions' => ['read']],
],Check permissions anywhere in your app to authorize your own resources:
if ($user->hasTeamPermission($user->currentTeam, 'update')) {
// allow editing a team-owned resource
}You may also register or override roles at runtime (e.g. in a service provider) via the
Teams facade:
use Teams; // alias for Devdojo\Teams\Teams
Teams::role('billing', 'Billing Manager', ['read', 'update'], 'Manages the team subscription.');With features.invitations enabled (the default), adding a member creates a
team_invitations row and emails a signed accept link:
| Method | URI | Name | Middleware |
|---|---|---|---|
GET |
/team-invitations/{invitation}/accept |
teams.invitations.accept |
web, auth, signed |
When the recipient clicks the link (and is logged in with the matching email), they're added to the team and switched onto it. The invitation email is a Markdown mailable — make sure your app's mailer is configured.
With invitations disabled, the member manager adds existing users directly by email (no mail is sent, and the email must already belong to a registered user):
'features' => [
'invitations' => false,
],$user->switchTeam($team); // returns false if the user doesn't belong to the teamThe bundled teams.team-switcher component does this for you and redirects to
config('teams.redirect_after_switch') (the {team} placeholder is replaced with the team id).
All write operations live in single-purpose, injectable action classes. They each validate and authorize, then fire the relevant event. Call them from your own controllers, jobs, or commands:
use Devdojo\Teams\Actions\CreateTeam;
use Devdojo\Teams\Actions\UpdateTeamName;
use Devdojo\Teams\Actions\AddTeamMember;
use Devdojo\Teams\Actions\InviteTeamMember;
use Devdojo\Teams\Actions\UpdateTeamMemberRole;
use Devdojo\Teams\Actions\RemoveTeamMember;
use Devdojo\Teams\Actions\DeleteTeam;
app(CreateTeam::class)->create($user, ['name' => 'Acme']);
app(UpdateTeamName::class)->update($user, $team, ['name' => 'Acme Inc.']);
app(AddTeamMember::class)->add($user, $team, 'jane@example.com', 'editor');
app(InviteTeamMember::class)->invite($user, $team, 'jane@example.com', 'editor');
app(UpdateTeamMemberRole::class)->update($user, $team, $memberId, 'admin');
app(RemoveTeamMember::class)->remove($user, $team, $member);
app(DeleteTeam::class)->delete($user, $team);| 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
});A TeamPolicy is registered automatically, so $user->can('update', $team) and friends work
everywhere (Blade @can, controllers, the actions above):
| 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 |
Publish the policy or point the teams.models.team config at your own subclass to customize.
When the devdojo/foundation metapackage is
installed, teams self-gates on its feature flag:
// config/foundation.php
'features' => [
'teams' => true, // flip to false (or toggle at /foundation/setup) to disable
],
'depends' => [
'teams' => ['auth'], // enabling teams ensures auth is enabled too
],When teams is disabled, the routes, Folio pages, Volt components, policy, and the personal-team
listener are not registered. The models, trait, and migrations remain available, so toggling is
lossless. Standalone (no Foundation present), the flag is absent and teams defaults to on.
| 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 |
|
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 |
URL prefix the pages live under |
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 & their permissions |
| Tag | Publishes to |
|---|---|
teams:config |
config/teams.php |
teams:migrations |
database/migrations |
teams:views |
resources/views/vendor/teams |
The bundled pages look unstyled.
Tailwind isn't generating the package's classes. Add the @source directive shown in the
Tailwind note, rebuild your CSS, or publish the views and integrate them with
your own layout.
No personal team is being created on registration.
Ensure features.personal_teams is true, your User uses the HasTeams trait, and the
Illuminate\Auth\Events\Registered event actually fires during your registration flow.
Invitation emails aren't sending.
Email invitations require a configured mailer. Set features.invitations => false to add
existing users directly instead (no mail needed).
current_team_id is null.
Call $user->switchTeam($team) (the switcher and create form do this for you), or rely on
$user->currentTeamOrDefault() which falls back to the personal/first team.
Where do the tables come from?
They're publish-only migrations — run
php artisan vendor:publish --tag=teams:migrations && php artisan migrate.
MIT © DevDojo