Team-scoped global scope trait
A BelongsToTeam trait that filters every query by the current team and stamps new rows automatically, failing closed when no team is set.
LaravelDatabasemulti-tenantglobal-scopeeloquenttrait
Code
<?php
namespace App\Models\Concerns;
use App\Support\CurrentTeam;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
trait BelongsToTeam
{
public static function bootBelongsToTeam(): void
{
static::addGlobalScope('team', new class implements Scope
{
public function apply(Builder $builder, Model $model): void
{
// Fails closed: no team means an error, never "all rows".
$builder->where($model->qualifyColumn('team_id'), app(CurrentTeam::class)->idOrFail());
}
});
static::creating(function (Model $model) {
$model->team_id ??= app(CurrentTeam::class)->idOrFail();
});
}
/** Explicit, greppable escape hatch for platform-level code. */
public static function withoutTeam(): Builder
{
return static::withoutGlobalScope('team');
}
}
<?php
namespace App\Support;
/** Holds the active team id for the current request or job. Register as a scoped binding. */
final class CurrentTeam
{
private ?int $id = null;
public function set(?int $id): void
{
$this->id = $id;
}
public function id(): ?int
{
return $this->id;
}
public function idOrFail(): int
{
return $this->id ?? throw new \RuntimeException('No team is set for this query.');
}
}
How it works
What it solves
In a multi-tenant app one forgotten where('team_id', ...) leaks another customer's data. Pushing the filter into the model means every query, relation and count is scoped by default, and new rows are stamped with the owner without anyone remembering to do it.
How it works
CurrentTeam is a tiny scoped singleton holding the active team id. Your middleware sets it after authentication, and queue jobs set it from their payload.
BelongsToTeam registers an anonymous global scope in bootEloquent-style bootBelongsToTeam. The scope adds where table.team_id = ? using the qualified column, so joins do not become ambiguous. A creating listener fills team_id when it is empty.
The important detail is failing closed. If no team is set, the scope throws instead of returning every row, which turns a missing middleware into a loud error rather than a silent leak. Admin screens that really need everything call withoutTeam(), which removes the scope for that query only and is easy to grep for in review.
Adapting it
Add the trait to each tenant-owned model and a team_id foreign key to each table. Replace RuntimeException with a domain exception your handler renders as a 403 if you prefer.
Pitfalls
- Queued jobs do not inherit the request context; restore the team in
handle. - Raw
DB::table()queries bypass the scope entirely. - Unique validation rules need
Rule::unique(...)->where('team_id', ...).
How to use it
- Add
app/Support/CurrentTeam.phpandapp/Models/Concerns/BelongsToTeam.php. - Bind it:
$this->app->scoped(CurrentTeam::class)in a service provider. - In authenticated middleware call
app(CurrentTeam::class)->set($request->user()->team_id). use BelongsToTeam;on every tenant model that has ateam_idcolumn.- Use
Site::withoutTeam()->count()only in platform admin code.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.