Laravel Multi-Tenancy: Complete Guide to Team-Based SaaS

By LaraSpeed Team · · 13 min read

1. What Is Multi-Tenancy?

Laravel multi-tenancy is the architecture pattern that allows a single application to serve multiple customers (tenants) while keeping their data completely isolated. In a team-based SaaS, each tenant is a team or organization — think Slack workspaces, GitHub organizations, or Notion teams.

When users sign up for your SaaS, they don't just create a personal account. They create (or join) a team. Every resource they create — projects, documents, invoices, API keys — belongs to their team, not to them personally. Other teams can never see that data, even though all teams share the same database and application code.

This guide covers the most common approach for SaaS applications: single-database multi-tenancy with team scoping. Every row in your database is associated with a team_id, and every query is automatically filtered to the current team. This is the approach used by tools like Jira, Linear, and Notion — and it's what LaraSpeed implements out of the box.

2. Multi-Tenancy Strategies in Laravel

Before writing code, you need to choose the right Laravel multi-tenancy strategy. There are three main approaches, each with different trade-offs:

Strategy 1: Single Database with Column Scoping

Every table has a team_id column. All tenants share one database. Queries are filtered by the current team using global scopes or middleware.

  • Pros: Simple, low overhead, easy to deploy, straightforward joins across teams (for admin dashboards).
  • Cons: Requires discipline to always scope queries. A missing where clause could leak data between tenants.
  • Best for: Most SaaS applications. This is the approach we'll use in this guide.

Strategy 2: Database Per Tenant

Each tenant gets their own database. The application switches database connections based on the current tenant.

  • Pros: Complete data isolation. Easier compliance with regulations (GDPR, HIPAA). Easy to back up or export a single tenant's data.
  • Cons: Complex migrations (must run on every tenant database). Higher infrastructure cost. Cross-tenant reporting is harder.
  • Best for: Enterprise SaaS with strict data isolation requirements. Packages like stancl/tenancy help with this approach.

Strategy 3: Schema Per Tenant (PostgreSQL)

One database, but each tenant gets their own PostgreSQL schema. Combines some benefits of both approaches.

  • Pros: Good isolation without the cost of separate databases. Native PostgreSQL support.
  • Cons: PostgreSQL-only. Schema migration complexity. Not widely supported by Laravel packages.
  • Best for: PostgreSQL shops that need stronger isolation without full database separation.

For 90% of SaaS applications, Strategy 1 (single database with column scoping) is the right choice. It's simpler to build, deploy, and maintain. The rest of this guide focuses exclusively on this approach.

3. Database Design for Teams

A solid Laravel teams implementation starts with the right database schema. You need three core tables: teams, the pivot table connecting users to teams, and a way to track each user's currently active team.

php artisan make:model Team -m
php artisan make:migration create_team_user_table
php artisan make:migration add_current_team_id_to_users_table

Here are the migrations:

// database/migrations/xxxx_create_teams_table.php
Schema::create('teams', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->foreignId('owner_id')->constrained('users')->cascadeOnDelete();
    $table->string('plan')->default('free');
    $table->json('settings')->nullable();
    $table->timestamps();

    $table->index('owner_id');
});
// database/migrations/xxxx_create_team_user_table.php
Schema::create('team_user', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained()->cascadeOnDelete();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('role')->default('member');
    $table->timestamps();

    $table->unique(['team_id', 'user_id']);
    $table->index('user_id');
});
// database/migrations/xxxx_add_current_team_id_to_users_table.php
Schema::table('users', function (Blueprint $table) {
    $table->foreignId('current_team_id')
        ->nullable()
        ->constrained('teams')
        ->nullOnDelete();
});

Every resource table in your application (projects, documents, invoices, etc.) needs a team_id foreign key:

// Example: projects table
Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained()->cascadeOnDelete();
    $table->foreignId('created_by')->constrained('users');
    $table->string('name');
    $table->text('description')->nullable();
    $table->timestamps();

    $table->index('team_id');
});

4. Building the Team Model and Relationships

The Team model is the core of your Laravel multi-tenancy system. It needs relationships to its owner, members, and all tenant-scoped resources.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;

class Team extends Model
{
    protected $fillable = ['name', 'slug', 'owner_id', 'plan', 'settings'];

    protected $casts = [
        'settings' => 'array',
    ];

    protected static function booted(): void
    {
        static::creating(function (Team $team) {
            if (empty($team->slug)) {
                $team->slug = Str::slug($team->name);
            }
        });
    }

    public function owner(): BelongsTo
    {
        return $this->belongsTo(User::class, 'owner_id');
    }

    public function members(): BelongsToMany
    {
        return $this->belongsToMany(User::class)
            ->withPivot('role')
            ->withTimestamps();
    }

    public function projects(): HasMany
    {
        return $this->hasMany(Project::class);
    }

    public function hasUser(User $user): bool
    {
        return $this->members()->where('user_id', $user->id)->exists();
    }

    public function userRole(User $user): ?string
    {
        return $this->members()
            ->where('user_id', $user->id)
            ->first()
            ?->pivot
            ->role;
    }
}

Add the team relationships to the User model:

// App\Models\User

public function teams(): BelongsToMany
{
    return $this->belongsToMany(Team::class)
        ->withPivot('role')
        ->withTimestamps();
}

public function currentTeam(): BelongsTo
{
    return $this->belongsTo(Team::class, 'current_team_id');
}

public function ownedTeams(): HasMany
{
    return $this->hasMany(Team::class, 'owner_id');
}

public function switchTeam(Team $team): void
{
    if (! $team->hasUser($this)) {
        throw new \InvalidArgumentException('User does not belong to this team.');
    }

    $this->update(['current_team_id' => $team->id]);
}

public function isOwnerOf(Team $team): bool
{
    return $this->id === $team->owner_id;
}

public function roleOn(Team $team): ?string
{
    return $team->userRole($this);
}

When a user registers, automatically create their personal team:

// App\Listeners\CreatePersonalTeam (listen for Registered event)

public function handle(Registered $event): void
{
    $team = Team::create([
        'name' => $event->user->name . "'s Team",
        'owner_id' => $event->user->id,
    ]);

    $team->members()->attach($event->user->id, ['role' => 'owner']);

    $event->user->update(['current_team_id' => $team->id]);
}

5. Roles and Permissions

In a team-based SaaS, roles define what each member can do within their team. A typical role hierarchy looks like this:

  • Owner: Full control. Can delete the team, manage billing, and transfer ownership.
  • Admin: Can manage members, change settings, and access all resources.
  • Member: Can create and manage their own resources. Cannot manage team settings or members.

Implement role checking with an enum and a simple authorization system:

<?php

namespace App\Enums;

enum TeamRole: string
{
    case Owner = 'owner';
    case Admin = 'admin';
    case Member = 'member';

    public function level(): int
    {
        return match ($this) {
            self::Owner => 3,
            self::Admin => 2,
            self::Member => 1,
        };
    }

    public function isAtLeast(self $role): bool
    {
        return $this->level() >= $role->level();
    }
}

Create a Gate or Policy to check team permissions:

// App\Policies\TeamPolicy

public function manageMembers(User $user, Team $team): bool
{
    $role = TeamRole::tryFrom($team->userRole($user));

    return $role?->isAtLeast(TeamRole::Admin) ?? false;
}

public function manageBilling(User $user, Team $team): bool
{
    return $user->isOwnerOf($team);
}

public function deleteTeam(User $user, Team $team): bool
{
    return $user->isOwnerOf($team);
}

Use these in your controllers and Blade templates:

// In a controller
$this->authorize('manageMembers', $team);

// In Blade
@can('manageMembers', $currentTeam)
    <a href="{{ route('team.members') }}">Manage Members</a>
@endcan

6. Setting the Team Context

The key to Laravel multi-tenancy is ensuring every request knows which team it belongs to. You need middleware that sets the current team context and makes it available throughout the request lifecycle.

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SetTeamContext
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = $request->user();

        if (! $user) {
            return $next($request);
        }

        // Ensure the user has a current team
        if (! $user->current_team_id) {
            $firstTeam = $user->teams()->first();

            if (! $firstTeam) {
                return redirect()->route('teams.create');
            }

            $user->update(['current_team_id' => $firstTeam->id]);
            $user->refresh();
        }

        // Verify the user still belongs to this team
        $team = $user->currentTeam;

        if (! $team || ! $team->hasUser($user)) {
            $user->update(['current_team_id' => null]);
            return redirect()->route('teams.select');
        }

        // Bind team to the container for global access
        app()->instance('currentTeam', $team);

        // Share with all views
        view()->share('currentTeam', $team);

        return $next($request);
    }
}

Register the middleware in your application:

// bootstrap/app.php (Laravel 12)
->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        \App\Http\Middleware\SetTeamContext::class,
    ]);
})

Create a helper to access the current team anywhere:

// app/helpers.php (autoloaded via composer.json)
function currentTeam(): ?\App\Models\Team
{
    return app()->bound('currentTeam') ? app('currentTeam') : null;
}

7. Scoping Queries to the Current Team

This is the most critical part of Laravel multi-tenancy. Every query for tenant-scoped data must be filtered to the current team. A missed scope means data leaks between tenants. Use a trait with a global scope to enforce this automatically:

<?php

namespace App\Models\Concerns;

use App\Models\Team;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait BelongsToTeam
{
    protected static function bootBelongsToTeam(): void
    {
        // Automatically scope all queries to the current team
        static::addGlobalScope('team', function (Builder $builder) {
            $team = currentTeam();
            if ($team) {
                $builder->where($builder->getModel()->getTable() . '.team_id', $team->id);
            }
        });

        // Automatically set team_id when creating
        static::creating(function (Model $model) {
            if (empty($model->team_id) && currentTeam()) {
                $model->team_id = currentTeam()->id;
            }
        });
    }

    public function team(): BelongsTo
    {
        return $this->belongsTo(Team::class);
    }
}

Apply this trait to every model that should be scoped to a team:

<?php

namespace App\Models;

use App\Models\Concerns\BelongsToTeam;
use Illuminate\Database\Eloquent\Model;

class Project extends Model
{
    use BelongsToTeam;

    protected $fillable = ['name', 'description', 'created_by'];
}

Now all queries are automatically scoped:

// This only returns projects for the current team
$projects = Project::all();

// This automatically sets team_id
$project = Project::create(['name' => 'New Project', 'created_by' => auth()->id()]);

// You can bypass the scope when needed (e.g., in admin panels)
$allProjects = Project::withoutGlobalScope('team')->get();

This pattern is the foundation of LaraSpeed's multi-tenancy implementation. Every tenant-scoped model uses the BelongsToTeam trait, ensuring data isolation across the entire application.

8. Team Invitations and Onboarding

Laravel teams need a way for owners and admins to invite new members. Build an invitation system with email-based invites and a signed URL for acceptance:

php artisan make:model TeamInvitation -m
// Migration
Schema::create('team_invitations', function (Blueprint $table) {
    $table->id();
    $table->foreignId('team_id')->constrained()->cascadeOnDelete();
    $table->foreignId('invited_by')->constrained('users');
    $table->string('email');
    $table->string('role')->default('member');
    $table->string('token', 64)->unique();
    $table->timestamp('expires_at');
    $table->timestamp('accepted_at')->nullable();
    $table->timestamps();

    $table->unique(['team_id', 'email']);
});

The invitation controller handles sending and accepting invitations:

<?php

namespace App\Http\Controllers;

use App\Mail\TeamInvitationMail;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;

class TeamInvitationController extends Controller
{
    public function store(Request $request, Team $team)
    {
        $this->authorize('manageMembers', $team);

        $validated = $request->validate([
            'email' => 'required|email',
            'role' => 'required|in:admin,member',
        ]);

        // Don't invite existing members
        if ($team->members()->where('email', $validated['email'])->exists()) {
            return back()->with('error', 'This user is already a team member.');
        }

        $invitation = TeamInvitation::create([
            'team_id' => $team->id,
            'invited_by' => $request->user()->id,
            'email' => $validated['email'],
            'role' => $validated['role'],
            'token' => Str::random(64),
            'expires_at' => now()->addDays(7),
        ]);

        Mail::to($validated['email'])->send(new TeamInvitationMail($invitation));

        return back()->with('success', 'Invitation sent.');
    }

    public function accept(string $token)
    {
        $invitation = TeamInvitation::where('token', $token)
            ->whereNull('accepted_at')
            ->where('expires_at', '>', now())
            ->firstOrFail();

        $user = auth()->user();

        // Add user to team
        $invitation->team->members()->attach($user->id, [
            'role' => $invitation->role,
        ]);

        $invitation->update(['accepted_at' => now()]);

        // Switch to the new team
        $user->switchTeam($invitation->team);

        return redirect()->route('dashboard')
            ->with('success', "You've joined {$invitation->team->name}!");
    }
}

9. Team-Based Billing

In many SaaS products, billing is tied to the team rather than the individual user. This means the Team model (not the User model) should be the Billable entity. This way, all team members share one subscription, and the team owner manages billing.

use Laravel\Cashier\Billable;

class Team extends Model
{
    use Billable;

    // Cashier uses the 'email' attribute for Stripe customer creation.
    // Since teams don't have email by default, use the owner's email:
    public function stripeEmail(): ?string
    {
        return $this->owner->email;
    }
}

Run the Cashier migrations against the teams table instead of users:

// In a migration or by modifying Cashier's published migration
Schema::table('teams', function (Blueprint $table) {
    $table->string('stripe_id')->nullable()->index();
    $table->string('pm_type')->nullable();
    $table->string('pm_last_four', 4)->nullable();
    $table->timestamp('trial_ends_at')->nullable();
});

Now create subscriptions on the team:

// Subscribe the current team
$team = currentTeam();

return $team->newSubscription('default', 'price_pro_monthly')
    ->trialDays(14)
    ->checkout([
        'success_url' => route('dashboard') . '?checkout=success',
        'cancel_url' => route('pricing'),
    ]);

// Check team subscription in middleware
if (currentTeam()->subscribed('default')) {
    // Team has an active subscription
}

// Gate access based on team plan
if (currentTeam()->subscribedToPrice('price_pro_monthly')) {
    // Team is on Pro plan
}

With team billing, you can also implement seat-based pricing — charging per team member. Check the LaraSpeed billing documentation for a complete implementation that supports Stripe, Paddle, and LemonSqueezy with team billing.

10. Team Switching

Users often belong to multiple teams (their personal workspace, their company, a freelance client, etc.). Build a team switcher that lets users move between teams instantly:

<?php

namespace App\Http\Controllers;

use App\Models\Team;
use Illuminate\Http\Request;

class TeamSwitchController extends Controller
{
    public function __invoke(Request $request, Team $team)
    {
        $user = $request->user();

        // Verify the user belongs to this team
        if (! $team->hasUser($user)) {
            abort(403, 'You do not belong to this team.');
        }

        $user->switchTeam($team);

        return redirect()->route('dashboard')
            ->with('success', "Switched to {$team->name}");
    }
}

In your Blade layout, add a team switcher dropdown:

<!-- Team Switcher (Blade + Alpine.js) -->
<div x-data="{ open: false }" class="relative">
    <button @click="open = !open" class="flex items-center gap-2">
        <span>{{ $currentTeam->name }}</span>
        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round"
                  stroke-width="2" d="M19 9l-7 7-7-7"/>
        </svg>
    </button>

    <div x-show="open" @click.away="open = false"
         class="absolute mt-2 w-56 bg-white rounded-lg shadow-lg">
        @foreach(auth()->user()->teams as $team)
            <form method="POST" action="{{ route('teams.switch', $team) }}">
                @csrf
                <button type="submit" class="block w-full text-left px-4 py-2
                    {{ $team->id === $currentTeam->id ? 'font-bold' : '' }}">
                    {{ $team->name }}
                    @if($team->id === $currentTeam->id)
                        <span class="text-green-500">&check;</span>
                    @endif
                </button>
            </form>
        @endforeach

        <hr>
        <a href="{{ route('teams.create') }}" class="block px-4 py-2">
            + Create New Team
        </a>
    </div>
</div>

11. Testing Multi-Tenancy

Testing Laravel multi-tenancy requires verifying that data isolation actually works. The most critical test: one team's data should never be visible to another team.

<?php

use App\Models\Project;
use App\Models\Team;
use App\Models\User;

test('users can only see projects from their current team', function () {
    // Create two teams with members
    $user = User::factory()->create();

    $teamA = Team::factory()->create(['owner_id' => $user->id]);
    $teamA->members()->attach($user->id, ['role' => 'owner']);

    $teamB = Team::factory()->create(['owner_id' => $user->id]);
    $teamB->members()->attach($user->id, ['role' => 'owner']);

    // Create projects in each team
    $projectA = Project::factory()->create(['team_id' => $teamA->id]);
    $projectB = Project::factory()->create(['team_id' => $teamB->id]);

    // Switch to Team A — should only see Team A's project
    $user->switchTeam($teamA);

    $this->actingAs($user)
        ->get('/projects')
        ->assertSee($projectA->name)
        ->assertDontSee($projectB->name);

    // Switch to Team B — should only see Team B's project
    $user->switchTeam($teamB);

    $this->actingAs($user)
        ->get('/projects')
        ->assertSee($projectB->name)
        ->assertDontSee($projectA->name);
});

test('users cannot access teams they do not belong to', function () {
    $user = User::factory()->create();
    $otherTeam = Team::factory()->create();

    $this->actingAs($user)
        ->post(route('teams.switch', $otherTeam))
        ->assertForbidden();
});

test('team owners can invite members', function () {
    $owner = User::factory()->create();
    $team = Team::factory()->create(['owner_id' => $owner->id]);
    $team->members()->attach($owner->id, ['role' => 'owner']);
    $owner->update(['current_team_id' => $team->id]);

    $this->actingAs($owner)
        ->post(route('team.invitations.store', $team), [
            'email' => 'newmember@example.com',
            'role' => 'member',
        ])
        ->assertSessionHas('success');

    $this->assertDatabaseHas('team_invitations', [
        'team_id' => $team->id,
        'email' => 'newmember@example.com',
    ]);
});

test('regular members cannot invite other members', function () {
    $member = User::factory()->create();
    $team = Team::factory()->create();
    $team->members()->attach($member->id, ['role' => 'member']);
    $member->update(['current_team_id' => $team->id]);

    $this->actingAs($member)
        ->post(route('team.invitations.store', $team), [
            'email' => 'another@example.com',
            'role' => 'member',
        ])
        ->assertForbidden();
});

LaraSpeed includes comprehensive multi-tenancy tests covering data isolation, team switching, invitations, role-based access, and team billing — over 30 tests just for the tenancy system.

12. Conclusion

Implementing Laravel multi-tenancy with teams is one of the most impactful architectural decisions in a SaaS application. Done right, it enables your users to collaborate, share resources, and manage their organization within your app. Done wrong, it leads to data leaks and authorization bugs that are hard to catch.

Here's a recap of what we covered:

  1. The three multi-tenancy strategies and when to use each
  2. Database design with teams, pivot tables, and team_id scoping
  3. Team model with ownership, membership, and role relationships
  4. Role-based authorization with PHP enums
  5. Middleware to set team context on every request
  6. Automatic query scoping with the BelongsToTeam trait
  7. Email-based team invitations with signed tokens
  8. Team-based billing with Laravel Cashier
  9. Team switcher UI with Alpine.js
  10. Testing data isolation between tenants

The biggest risk with Laravel teams is forgetting to scope a query or missing a team_id on a new table. The BelongsToTeam trait and global scopes mitigate this risk by making tenant scoping automatic and opt-out rather than opt-in.

If you want a head start, check out our complete guide to building a SaaS with Laravel or explore LaraSpeed's production-ready implementation below.

Multi-tenancy is hard. We've already built it.

LaraSpeed's Pro tier includes a complete multi-tenancy system with teams, roles, invitations, team billing, scoped queries, and 30+ tests — ready to customize and ship.

One-time purchase · Full source code · 14-day money-back guarantee