Skip to content

Start building your next app or website — with AI, right in your browser.

Visit devdojo.com
Docs
Teams

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 the team_user pivot.
  • 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.

© 2026 DevDojo Edit this page