DEV3 Technologies home

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');
    }
}

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

  1. Add app/Support/CurrentTeam.php and app/Models/Concerns/BelongsToTeam.php.
  2. Bind it: $this->app->scoped(CurrentTeam::class) in a service provider.
  3. In authenticated middleware call app(CurrentTeam::class)->set($request->user()->team_id).
  4. use BelongsToTeam; on every tenant model that has a team_id column.
  5. 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.

Related snippets