NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Teams for Laravel

Data isolation

For B2B apps where a team is a customer company: every team-scoped model only reads and writes the current team’s rows, the current team follows the request into queued jobs, and a forgotten context returns nothing instead of everything. It is opt-in — nothing changes BelongsToTeam, HasTeams, roles or any existing query until a model uses ScopedToTeam or a route uses the teams.context middleware.

The current team

use RoundlyConsulting\Teams\Facades\Teams;

Teams::switchTo($team);              // one team — CHECKED against the signed-in user
Teams::switchTo($team, $someone);    // checked against someone else
Teams::setCurrent($team);            // one team — UNCHECKED, for system code only
Teams::current();                    // ?Team — null without a context or in multi-team mode
Teams::currentMany();                // Collection<Team> — every team in context
Teams::setCurrent(null);             // clear

Teams::runAs($team, function (Team $team) {
    // scoped to $team; the previous context is restored afterwards — also on an exception
});
Teams::for($team)->run(fn () => Project::count());   // the same, from the team handle

// Multi-team mode, for "all my teams" dashboards — every team is checked
Teams::setCurrentMany($user->teams()->with('team')->get()->pluck('team'));
  • switchTo() is the way to set a team a user picked. It sets the team only when the user — the signed-in one by default — holds an active membership of it, or of a team above it when roles inherit down. Otherwise it throws TeamContextException and leaves the context as it was; without any user it throws too.
  • setCurrent() is unchecked, for system code acting for a team — webhooks, commands, seeders. Never pass it a team id from user input.
  • setCurrentMany() checks every team, all or nothing; an empty set scopes to nothing. Expired memberships do not count.
  • runAs() runs unchecked, returns the callback’s result and restores the previous context — none, one team or a set — also when the callback throws. A returned PendingDispatch is dispatched inside the context, so the job carries the right team.
  • Every change fires TeamContextSet.

Queued jobs and commands

The context lives in Laravel’s hidden Context, so it never appears in log lines and every job dispatched while it is set restores it before it runs. A job dispatched without a context runs without one, and one job’s context never leaks into the next. Console commands and scheduled tasks start empty — set a team with runAs():

// In a request for team A:
Teams::switchTo($teamA);
GenerateInvoices::dispatch();   // runs as team A on the worker

// From a console command or scheduled task, which start without a context:
Teams::runAs($team, fn () => GenerateInvoices::dispatch());

A job captures the context when it is dispatched and runs later. Turn on teams.context.recheck_queued_jobs to close that gap: a context set through a membership check — the middleware, switchTo(), setCurrentMany() — carries its member into every job dispatched from it, and when the job starts the member is checked again against the database as it is then. If they were removed, expired or deleted, or a team was deleted, the context is cleared and the job fails with TeamContextException before handle() runs — without a retry, because a revoked membership does not come back. failed() and JobFailed run as for any failure. Contexts from setCurrent() or runAs() carry no member and are never re-checked, and the check does not cover a membership revoked while handle() is already running. The option is off by default in 2.x and planned to be on in the next major.

A package working in the background has to enter the team too: lifecycle-for-laravel runs each ScopedToTeam subject inside Teams::runAs() of its own team when its sweep, queued schedules, expiry warnings, retries and lifecycle:adopt act on it — no setup.

The teams.context middleware

Route::middleware(['auth', 'teams.context'])->prefix('/teams/{team}')->group(function () {
    Route::get('/projects', [ProjectController::class, 'index']);
    Route::get('/projects/{project}', [ProjectController::class, 'show']);   // bound inside the context
});

Route::middleware(['auth', 'teams.context:company'])->get('/companies/{company}/invoices', ...);
Route::get('/teams/{team:slug}/projects', ...)->middleware(['auth', 'teams.context']);   // slug binding
  • It reads the route parameter — team by default, context.route_parameter, or teams.context:<name> per route — and binds it through the team model’s route binding, so getRouteKeyName(), {team:slug} and ULID columns all work. A route without the parameter throws TeamContextException; soft-deleted teams are not found.
  • It lets through only a signed-in user for whom $team->isAccessibleBy($user) is true, and sets the context through the checked Teams::switchTo().
  • A missing team, a non-member, an expired member and a guest get the same answer — 404 by default, 403 with TEAMS_CONTEXT_DENY_STATUS=403 — so probing reveals nothing about other customers.
  • It runs right before SubstituteBindings, so {project} above is bound inside the context: team B’s project id under team A’s URL is a 404, never team B’s row.

Team-scoped models

use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Teams\Traits\ScopedToTeam;

class Project extends Model
{
    use ScopedToTeam;
}

class Invoice extends Model
{
    use ScopedToTeam;

    public const TEAM_COLUMN = 'company_id';   // per-model column, team_id by default
}

Add the column with the teamScope() migration macro. It types the column like the team key — teams.primary_key_type, or the key of a HasUuids or HasUlids Team subclass — not like key_type. The default delete behaviour restricts: force-deleting a team that still owns rows fails:

Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->teamScope();                      // team_id: typed like the team key, indexed, FK onto teams
    $table->teamScope('company_id');          // a custom column
    $table->teamScope()->cascadeOnDelete();   // returns the ForeignKeyDefinition
    $table->teamScope(nullable: true);
});
  • Reads — one team in context filters by its key, a multi-team set by IN (…), and no context returns no rows, or throws TeamContextException with scoping.without_context set to throw. That covers get, find, findOrFail (a 404), count, exists, whereHas, eager and lazy relations, route-model binding and the WHERE of mass update() and delete().
  • Writes — with one team in context a blank team column is filled with it and any other team throws; in multi-team mode the team must be given explicitly and be in the set; without a context every write throws. Updates, deletes, restores and force deletes check the stored team and any new one, before anything is written.
  • A row loaded through an escape hatch is still guarded: reading team B’s row without the scope and saving it from team A’s context throws.

Escape hatches

Every place isolation is lifted is greppable — grep -rn "withoutTeamScope\|forTeam(" app/ lists them all:

Project::withoutTeamScope()->count();               // this query ignores the context
Project::forTeam($teamB)->get();                    // this query reads team B
Project::forTeam([$a, $b])->get();                  // Teams, keys or a collection; [] matches nothing
Teams::withoutTeamScope(fn () => $project->update(['team_id' => $b->id]));   // reads and writes, this process only

What the scope does not see

It is an Eloquent global scope plus model events, so these bypass it: DB::table(), toBase() and raw SQL; mass insert() and upsert(), and the new team value of a mass update() (its WHERE is scoped); saveQuietly(), deleteQuietly() and withoutEvents(), which skip the write checks while reads stay scoped; and withoutGlobalScopes() with no arguments, which drops the team scope with every other one — name the scope you mean instead.

Scoping another package’s model

Subclass the model, add the trait and point the package’s model config at your class — global scopes and model events are per class, so only your subclass is scoped:

// tickets-for-laravel ships Ticket; scope it in your app:
class Ticket extends \RoundlyConsulting\Tickets\Models\Ticket
{
    use ScopedToTeam;
}

// config/tickets.php
'models' => ['ticket' => App\Models\Ticket::class],

Add the column with a migration of your own. The other package must resolve the model through its config, as every Roundly package does — anything it does with the parent class directly or through DB::table() is not scoped. Rows it creates get the current team and throw without a context, so run its commands and jobs inside Teams::runAs().

Unique values, cache keys and files

UniqueInTeam checks uniqueness among one team’s rows of a scoped model. Without a context — or in multi-team mode without inTeam() — it throws TeamContextException instead of guessing:

use RoundlyConsulting\Teams\Rules\UniqueInTeam;

'slug' => ['required', UniqueInTeam::for(Project::class, 'slug')],
'slug' => [UniqueInTeam::for(Project::class, 'slug')->ignore($project)],   // a model or a key
'slug' => [UniqueInTeam::for(Project::class, 'slug')->inTeam($team)],      // an explicit team

scopedKey() gives every team its own prefix for caches and stored files, built from the primary key — never a renameable slug. A . or .. segment or a NUL byte throws TeamsException, and an unsaved team throws TeamContextException:

Cache::remember(Teams::current()->scopedKey('stats'), 600, fn () => ...);   // teams/42/stats
Storage::put($team->scopedKey('invoices/2026-11.pdf'), $pdf);              // teams/42/invoices/2026-11.pdf

The doctor

php artisan teams:doctor

It checks the team key first: teams.primary_key_type, the key the team model writes and the type of every column holding a team key must agree — a HasUlids subclass with the setting left at bigint, or an integer team_id for ULID teams, fails. Then it lists every scoped model — the classes in scoping.models plus every concrete ScopedToTeam model under scoping.discover — and fails when one lacks its table, its team column or an index whose first column is the team column; without that index every request scans every tenant’s rows. A column not migrated yet is listed, not failed. Run it in CI or a test:

it('keeps every team-scoped table safe', function () {
    $this->artisan('teams:doctor')->assertSuccessful();
});

Programmatically, Teams::scopedModels() returns a ScopedModelReport per model and Teams::teamKeys() a TeamKeyReport, each with isHealthy() and problems().

Testing

InteractsWithTeams adds actingInTeam($user, $team, ?$guard): it signs the user in and makes the team current, without a membership check — routes behind teams.context set their own team from the URL anyway. Seed scoped data inside Teams::runAs():

use RoundlyConsulting\Teams\Testing\Concerns\InteractsWithTeams;

uses(InteractsWithTeams::class)->in('Feature');

it('lists the team projects', function () {
    Teams::runAs($team, fn () => Project::factory()->count(3)->create());

    $this->actingInTeam($user, $team)
        ->get('/dashboard')
        ->assertOk();
});

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.