Teams
Introduction
When running nubos:init and selecting Team as the organization type, your application is scaffolded to support team creation and management. Each registered user is assigned a "Personal" team on creation and can create or join additional teams.
Team Creation
Actions
Team creation and deletion logic can be customized by modifying the action class at app/Actions/Teams/CreateTeamAction.php:
class CreateTeamAction
{
public function execute(User $owner, array $data): Team
{
return DB::transaction(function () use ($owner, $data): Team {
$team = Team::query()->create([
'name' => $data['name'],
'slug' => $data['slug'] ?? Str::slug($data['name']),
'owner_id' => $owner->id,
'personal_team' => $data['personal_team'] ?? false,
]);
$team->users()->attach($owner->id, ['role' => 'owner']);
$owner->update(['current_team_id' => $team->id]);
event(new TeamCreated($team));
return $team;
});
}
}The action wraps everything in a DB::transaction(): it creates the team, attaches the owner as a member with the owner role, updates the user's current_team_id, and dispatches a TeamCreated event.
Inspecting User Teams
User team information is accessible through methods provided by the HasTeams trait, automatically added to App\Models\User during installation:
// Access all teams a user belongs to...
$user->teams(): BelongsToMany
// Access all teams owned by the user...
$user->ownedTeams(): HasMany
// Access the user's currently selected team...
$user->currentTeam(): BelongsTo
// Determine if a user belongs to a given team...
$user->belongsToTeam($team): boolThe Current Team
Every user has a "current team" tracked by the current_team_id column on the users table. The SetCurrentTeam middleware updates this value automatically when a user visits a team URL. The RedirectToCurrentTeam middleware uses it to redirect users to their last-used team context.
The Team Object
The Team model provides relationships to inspect its members:
// Access the team's owner...
$team->owner(): BelongsTo
// Access all team members (including owner)...
$team->users(): BelongsToManyThe users() relationship includes a role pivot column. Every team member has a role (e.g., owner, member).
Member Management
Adding Members
The AddTeamMemberAction attaches a user to the team's pivot table with an optional role:
class AddTeamMemberAction
{
public function execute(Team $team, User $user, ?string $role = null): void
{
$team->users()->attach($user->id, ['role' => $role]);
event(new TeamMemberAdded($team, $user));
}
}Removing Members
The RemoveTeamMemberAction detaches a user:
class RemoveTeamMemberAction
{
public function execute(Team $team, User $user): void
{
$team->users()->detach($user->id);
event(new TeamMemberRemoved($team, $user));
}
}Middleware
SetCurrentTeam
The SetCurrentTeam middleware resolves the team from the {team} route parameter (by slug), verifies the authenticated user is a member, stores the team on the request attributes, and updates the user's current_team_id:
class SetCurrentTeam
{
public function handle(Request $request, Closure $next): Response
{
$team = $request->route('team');
if (! $team instanceof Team) {
$team = Team::query()->where('slug', $team)->first();
}
if (! $team) {
abort(404);
}
if (! $request->user()->belongsToTeam($team)) {
abort(403);
}
$request->attributes->set('current_team', $team);
if ($request->user()->current_team_id !== $team->id) {
$request->user()->update(['current_team_id' => $team->id]);
}
return $next($request);
}
}RedirectToCurrentTeam
The RedirectToCurrentTeam middleware redirects unauthenticated paths to the user's last-used team:
class RedirectToCurrentTeam
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$team = $user->currentTeam ?? $user->teams()->first();
if (! $team) {
return $next($request);
}
$path = $request->path();
return redirect("/teams/{$team->slug}/{$path}");
}
}Routes
All team routes are scoped under /teams/{team} and use the SetCurrentTeam middleware:
Route::middleware(['web', 'auth', SetCurrentTeam::class])
->prefix('teams/{team}')
->group(function (): void {
Route::get('/dashboard', function () {
return inertia('Dashboard');
})->name('dashboard');
});Routes are loaded by NubosOrganizationServiceProvider:
class NubosOrganizationServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->app['router']->aliasMiddleware('set-current-team', SetCurrentTeam::class);
$this->app['router']->aliasMiddleware('redirect-to-current-team', RedirectToCurrentTeam::class);
$this->loadRoutesFrom(base_path('routes/team.php'));
}
}Events
The following events are dispatched by team actions:
| Event | Dispatched By |
|---|---|
TeamCreated | CreateTeamAction |
TeamMemberAdded | AddTeamMemberAction |
TeamMemberRemoved | RemoveTeamMemberAction |
All events are located in app/Events/Teams/ and accept the relevant models as readonly constructor properties.
Database Schema
Three migrations are generated:
teams table:
Schema::create('teams', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->string('name');
$table->string('slug')->unique();
$table->foreignUuid('owner_id')->constrained('users')->cascadeOnDelete();
$table->boolean('personal_team')->default(false);
$table->timestamps();
$table->softDeletes();
});team_user pivot table:
Schema::create('team_user', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('team_id')->constrained('teams')->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained()->cascadeOnDelete();
$table->string('role')->nullable();
$table->timestamps();
$table->softDeletes();
$table->unique(['team_id', 'user_id']);
});current_team_id on users:
Schema::table('users', function (Blueprint $table): void {
$table->foreignUuid('current_team_id')->nullable()->constrained('teams')->nullOnDelete();
});Configuration
The generated config/nubos.php for a Team organization:
return [
'organization_type' => 'team',
'organization_model' => \App\Models\Team::class,
'has_sub_teams' => false,
];