Skip to content

Data Model Strategy: User, Team & Company Relationships

Current Architecture

User (1) ─────┬───── (*) Company     ← User owns brands directly
              ├───── (1) Team        ← User creates/owns a team
              │         │
              │         └── (*) TeamMember ← Multiple users can join
              └───── (?) team_id     ← User's "active" team (ambiguous)

Current Issues

  1. User.team_id is ambiguous - is it "owned team" or "active team"?
  2. Company belongs to User, not Team - limits collaboration
  3. Partner belongs to Team, but Company doesn't - inconsistent
  4. One user can only "belong to" one team at a time

Team (Organization)
  ├── TeamMember (pivot) ──── User
  │     └── role: owner | admin | member | viewer
  ├── Company (Brand)
  │     └── team_id (required)
  ├── Partner (Co-Sell Profile)
  │     └── team_id (required)
  └── Product (Listings)
        └── team_id (required)

Key Changes

  1. Team is the tenant, not User
  2. User can belong to MANY teams via TeamMember (many-to-many)
  3. Company moves from User to Team ownership
  4. All resources scoped to Team

Relationship Questions & Answers

Can one user have many team_ids?

Currently: No - users.team_id is singular. But TeamMember already supports this!

Recommended: Yes, via TeamMember pivot:

// User model
public function teams(): BelongsToMany
{
    return $this->belongsToMany(Team::class, 'team_members')
        ->withPivot('role', 'status')
        ->wherePivot('status', 'joined');
}

// Get user's active team (could store in session/preference)
public function activeTeam(): ?Team
{
    return $this->teams()->first(); // or use preference
}

What if 2 users want to join the same team?

Already supported! TeamMember handles this:

// Invite user to team
TeamMember::create([
    'team_id' => $team->id,
    'email' => 'colleague@example.com',
    'role' => 'member',
    'status' => 'waiting', // pending invitation
]);

// When user accepts
$teamMember->update([
    'user_id' => $user->id,
    'status' => 'joined',
    'joined_at' => now(),
]);

Data Isolation Strategy

Current Hierarchy (Good Foundation)

User Level     → training_data_opt_out, analytics_opt_in
  ↓ inherits
Team Level     → training_data_opt_out (nullable = inherit)
  ↓ inherits
Company Level  → data_isolation_enabled, kb_cross_brand_sharing
  ↓ inherits
Product Level  → training_data_opt_out (nullable = inherit)

Add explicit scoping at query level:

// Scope trait for team-based resources
trait BelongsToTeam
{
    public function scopeForTeam($query, ?int $teamId = null)
    {
        $teamId = $teamId ?? auth()->user()->activeTeam()?->id;

        return $query->where(function ($q) use ($teamId) {
            $q->where('team_id', $teamId)
              ->orWhereJsonContains('team_access->team_ids', $teamId);
        });
    }
}

// Usage
Company::forTeam()->get(); // Auto-scoped to active team

Migration Plan

Phase 1: Add team_id to companies

// Migration
Schema::table('companies', function (Blueprint $table) {
    $table->foreignId('team_id')->nullable()->after('user_id');
});

// Backfill: assign companies to user's primary team
Company::whereNull('team_id')->each(function ($company) {
    $team = Team::where('user_id', $company->user_id)->first();
    if ($team) {
        $company->update(['team_id' => $team->id]);
    }
});

Phase 2: Update queries to support both

// Transition period: support both user_id and team_id
public function scopeAccessible($query)
{
    $user = auth()->user();

    return $query->where(function ($q) use ($user) {
        $q->where('user_id', $user->id)
          ->orWhere('team_id', $user->team_id)
          ->orWhereJsonContains('team_access->team_ids', $user->team_id);
    });
}

Phase 3: Make team_id required

// After backfill is complete
Schema::table('companies', function (Blueprint $table) {
    $table->foreignId('team_id')->nullable(false)->change();
});

Priority Change Effort Impact
High Add team_id to companies table Low Enables team collaboration on brands
High Fix User.team() relationship logic Low Fixes current bugs
Medium Add active_team_id to users (or session) Low Multi-team UX
Medium Create BelongsToTeam trait for scoping Medium Consistent data isolation
Low Remove singular users.team_id Medium Cleanup after migration

Current Table Relationships

Relationship Type Parent Child Notes
User → Team (created) One-to-Many User.id Team.user_id User can create multiple teams
User → TeamMember One-to-Many User.id TeamMember.user_id Nullable, allows pending invites
Team → TeamMembers One-to-Many Team.id TeamMember.team_id CASCADE delete
User → Company One-to-Many User.id Company.user_id User owns companies
Company → Products One-to-Many Company.id Product.company_id Products belong to companies
Product → Team Many-to-One Team.id Product.team_id Products can be team-assigned
Team → Partners One-to-Many Team.id Partner.team_id Co-sell partners per team
Partner ↔ Partner Many-to-Many - cosell_relationships Co-sell relationships

Key Files

  • app/Models/User.php - User model with team relationships
  • app/Models/Team/Team.php - Team model
  • app/Models/Team/TeamMember.php - TeamMember pivot model
  • app/Models/Company.php - Company model with data isolation
  • app/Models/Product.php - Product model with team access
  • app/Extensions/ContentManager/System/Models/Partner.php - Partner model

Last updated: January 2026