Laravel multi tenant guard
Skill shaxzodbek-uzb/laravel-guardrails/skills/laravel-multi-tenant-guard
π‘οΈ Production guardrail skills for AI-assisted Laravel β stop cross-tenant leaks, N+1, destructive migrations before your AI agent ships them. For Claude Code, Cursor & 40+ agents.
npx -y skills add shaxzodbek-uzb/laravel-guardrails --skill laravel-multi-tenant-guardAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
This skill should be used when the agent works in a multi-tenant / SaaS Laravel app β any time it writes or edits a model, controller, action, Policy, route, job, listener, command, API Resource, cache key, or file path that touches tenant-owned data. Load it when it sees or adds tenant_id / team_id / account_id / organization_id / company_id columns, a BelongsToTenant trait, global scopes, route-model binding (`/{post}`), Rule::exists / exists validation on a foreign key, Filament panel tenancy (->tenant()), or queued jobs that query tenant data; and whenever the user mentions multi-tenant, tenancy, tenant isolation, cross-tenant leak, data leak, IDOR, stancl/tenancy, or spatie/laravel-multitenancy. Loads the guardrails that prevent one tenant from reading or mutating another tenant's data.
The file declares its own license as MIT. That is the authorβs claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.
SKILL.md
14.3 KB, as published. Nobody here has run it
π‘οΈ Multi-Tenant Guardrail
Prevents the single most expensive bug a SaaS can ship: a cross-tenant data leak. One missing where tenant_id = ?, one unscoped route binding, one job that lost its tenant context β and customer A reads, edits, or deletes customer B's data.
The footgun
In a multi-tenant app every tenant-owned query must be scoped to the current tenant. Miss it once and you have a breach: an attacker (or just a buggy link) reaches another tenant's records. These leaks are silent β the code returns a 200 with the wrong data, no error, no log β and they pass review because the line looks like normal Eloquent.
The expensive lesson behind this skill: a production CRM where almost every controller action authorized correctly β 156 of 160. The other 4 skipped the check and leaked across tenants. "Almost all actions are covered" is exactly how breaches happen. Coverage must be 100%, and it must be enforced by the framework, not by reviewer diligence. The rules below default to fail-closed: scope at the lowest layer, authorize every action, and make the dangerous path the one that requires explicit, visible opt-out.
Rules
- NEVER trust a tenant identifier from request input β not the body, query string, route parameter, or a client-set header. Derive the current tenant only from server-side authenticated context: the logged-in user's tenant, the resolved subdomain/domain, or a signed token verified on the server. Accepting
tenant_idfrom input is a direct IDOR. - Scope at the lowest layer so it cannot be forgotten. Put a global scope on every tenant-owned model via a
BelongsToTenanttrait that (a) filters all queries by the current tenant and (b) auto-fillstenant_idon thecreatingevent. A developer (or an AI agent) writingPost::all()then gets only this tenant's posts automatically β the safe default is the only default. tenant_idmust NEVER be mass-assignable from user input. Keep it out of$fillable; set it server-side (the trait does this oncreating). Otherwise a crafted request reassigns a record to another tenant.- Authorize EVERY action with a Policy β and the policy must verify the record belongs to the current tenant, not merely that the user has a role. Use
Gate::authorize(),$request->user()->can(), thecanroute middleware, or$this->authorize()/authorizeResource(). Note: since Laravel 11 the slim base controller no longer pulls in theAuthorizesRequeststrait, so$this->authorize()/authorizeResource()only exist if youuse AuthorizesRequestsinapp/Http/Controllers/Controller.phpβ otherwise preferGate::authorize(), which always works. Enforce 100% coverage with an architecture test (below) or a base controller that fails closed. A role check without an ownership check still leaks. - Scope route-model binding.
/posts/{post}will happily resolve another tenant's post unless scoped. Rely on the global scope (so a foreign id throwsModelNotFoundExceptionβ 404) and/or use scoped bindings for nested routes (->scopeBindings()/Route::scopeBindings()). NeverPost::find($id)straight from a route id without tenant scoping. - Validate foreign keys scoped to the tenant. When input carries a related id (
category_id,assignee_id), validate it withRule::exists(...)->where('tenant_id', $tenantId). An unscopedexists:categories,idlets a tenant attach another tenant's row β relationship smuggling. - Re-establish tenant context inside background work. Jobs, queued listeners, notifications, scheduled commands, and exports run outside the request, where the "current tenant" is gone. Capture the tenant id at dispatch and restore it (set the current tenant) at the start of
handle()before any tenant-scoped query. Otherwise the global scope runs with no tenant (returns everything) or the worker's leftover tenant (the previous job's) β a severe, easy-to-miss leak. Tenancy packages provide helpers (e.g.tenancy()->initialize($tenant)); if hand-rolled, set your container-bound current tenant explicitly. - Namespace cache and rate-limit keys by tenant β
"tenant:{$id}:dashboard", never a bare"dashboard". A shared key serves one tenant's cached data to another. - Scope file storage paths and signed URLs by tenant. Store under
tenants/{id}/...and never build a path or signed URL that another tenant can guess or enumerate. - In Filament, use first-class tenancy (
$panel->tenant(Team::class, ownershipRelationship: 'team'), theHasTenantscontract withgetTenants()/canAccessTenant(), and the auto-scoped resource query β overridescopeEloquentQueryToTenant()only if you must) rather than rolling your own β then still apply the Policy + scoping rules above. Resource queries must remain tenant-scoped; remember Select/Repeater/relation-manager queries are NOT auto-scoped, so scope those yourself. - Use a proven package unless you have a reason not to.
stancl/tenancy(multi-database / domain-based) andspatie/laravel-multitenancy(single or multi DB) are battle-tested. This skill is the guardrails that apply whichever you use β including a hand-rolled single-DBtenant_idcolumn. Do not mandate a package; do enforce the rules. - A cross-tenant isolation test is MANDATORY, not optional. Every tenant-owned resource needs a test proving tenant B gets 403/404 β and no mutation β on tenant A's record (see How to verify). Treat a missing isolation test like a missing migration.
Good vs bad
The BelongsToTenant trait + global scope (the backbone)
// app/Models/Scopes/TenantScope.php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
// currentTenant() resolves from auth/subdomain β NEVER from request input
if ($tenantId = app('currentTenant')?->id) {
$builder->where($model->getTable().'.tenant_id', $tenantId);
}
}
}
// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;
use App\Models\Scopes\TenantScope;
trait BelongsToTenant
{
public static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
// server-side only; ignores any tenant_id coming from input
if (! $model->tenant_id && $tenant = app('currentTenant')) {
$model->tenant_id = $tenant->id;
}
});
}
}
// β
every tenant-owned model just uses the trait β scoping is automatic
class Post extends Model
{
use BelongsToTenant;
protected $fillable = ['title', 'body']; // β tenant_id NOT fillable
}
Controller: leaking vs scoped + authorized
// β leaks: find() ignores tenant; no authorization; trusts route id blindly
public function show(int $id)
{
return Post::find($id); // returns ANY tenant's post
}
// β also leaks: "user is a manager" is not "this post is theirs"
public function update(Request $request, Post $post)
{
abort_unless($request->user()->isManager(), 403);
$post->update($request->all());
return $post;
}
// β
global scope makes binding tenant-safe (foreign id β 404),
// and the Policy verifies ownership for THIS action
use Illuminate\Support\Facades\Gate;
public function update(UpdatePostRequest $request, Post $post)
{
Gate::authorize('update', $post); // PostPolicy::update checks tenant ownership
// (or $this->authorize(...) if your base controller `use`s AuthorizesRequests)
$post->update($request->validated()); // tenant_id can't be reassigned
return $post;
}
// app/Policies/PostPolicy.php β ownership, not just role
public function update(User $user, Post $post): bool
{
return $post->tenant_id === $user->tenant_id
&& $user->can('posts.update');
}
Foreign-key smuggling
// β tenant B can pass tenant A's category_id and attach it
$request->validate(['category_id' => 'required|exists:categories,id']);
// β
the related row must belong to the current tenant
use Illuminate\Validation\Rule;
$request->validate([
'category_id' => [
'required',
Rule::exists('categories', 'id')->where('tenant_id', app('currentTenant')->id),
],
]);
Background jobs lose tenant context
// β job runs with NO current tenant β global scope returns everything,
// or reuses the worker's previous tenant β cross-tenant write
class GenerateReport implements ShouldQueue
{
public function __construct(public int $reportId) {}
public function handle(): void
{
$report = Report::find($this->reportId); // wrong/no tenant scope here
// ... touches Post::all(), etc. β leaks across tenants
}
}
// β
carry the tenant id, re-establish it before any tenant-scoped query
class GenerateReport implements ShouldQueue
{
public function __construct(public int $tenantId, public int $reportId) {}
public function handle(): void
{
$tenant = Tenant::withoutGlobalScopes()->findOrFail($this->tenantId);
app()->instance('currentTenant', $tenant); // restore context
// (with a package: tenancy()->initialize($tenant);)
$report = Report::findOrFail($this->reportId); // now correctly scoped
// ...
}
}
The mandatory cross-tenant isolation test
use App\Models\{Post, Tenant, User};
it('forbids reading or mutating another tenant\'s post', function () {
$tenantA = Tenant::factory()->create();
$tenantB = Tenant::factory()->create();
$postA = Post::factory()->for($tenantA)->create();
$userB = User::factory()->for($tenantB)->create();
actingAs($userB);
app()->instance('currentTenant', $tenantB);
// hidden by the global scope β 404, not 403-with-leak
$this->getJson("/api/posts/{$postA->id}")->assertNotFound();
$this->putJson("/api/posts/{$postA->id}", ['title' => 'hacked'])
->assertNotFound();
// and nothing was mutated
expect($postA->fresh()->title)->not->toBe('hacked');
});
How to verify
Run from the project root after any change to tenant-owned code.
# 1. Tenant-owned models must use the trait / a global scope. List models with a
# tenant_id column, then confirm each uses BelongsToTenant.
grep -rln "tenant_id\|team_id\|account_id" database/migrations
grep -rL "BelongsToTenant" app/Models # files MISSING the trait β audit each
# 2. Route ids resolved without tenant scoping (potential IDOR)
grep -rnE "::find\(|::findOrFail\(" app/Http/Controllers
# 3. Unscoped foreign-key validation (relationship smuggling)
grep -rn "exists:" app/Http/Requests app/Http/Controllers # each must be tenant-scoped
# 4. tenant_id leaking into mass assignment
grep -rn "tenant_id" app/Models | grep -i "fillable" # should be EMPTY
# 5. Jobs that query tenant data but never restore context
grep -rL "currentTenant\|tenancy()->initialize" app/Jobs # audit each that touches models
# 6. Bare (non-namespaced) cache keys
grep -rnE "Cache::(remember|put|get)\(" app/ | grep -v "tenant"
# 7. Run the suite β the isolation tests must pass
./vendor/bin/pest # or: php artisan test
Enforce trait coverage with an architecture test so a new tenant model can't ship unscoped:
// tests/Arch.php (Pest)
arch('tenant-owned models use BelongsToTenant')
->expect('App\Models')
->classes()
// narrow this to your actual tenant-owned models, or invert via ->ignoring(...)
->toUseTrait('App\Models\Concerns\BelongsToTenant');
If you use stancl/tenancy or spatie/laravel-multitenancy, verify the bootstrappers/scoping are registered and that queued jobs are tenant-aware per that package's docs (both ship middleware/listeners for this) β but still keep the Policy + isolation-test rules; the package scopes data, it does not authorize actions for you.
When it's OK to bend the rule
- Central / shared models (the
Tenanttable itself, global plans, system settings) are intentionally not tenant-scoped. Mark them clearly and access tenant rows on them withwithoutGlobalScopes()only in vetted, central code (e.g. the job re-hydration above). - Super-admin / impersonation flows legitimately cross tenants. Gate them behind an explicit ability, log every access, and never reuse the normal request path β make the cross-tenant capability loud and audited.
- Multi-database tenancy (
stancl/tenancydomain mode) isolates at the connection level, so per-querytenant_idscoping may be redundant β but jobs/cache/storage context rules (7β9) still apply, and isolation tests are still mandatory.
References
- Authorization & Policies: https://laravel.com/docs/authorization
- Global scopes (
addGlobalScope,Scopecontract): https://laravel.com/docs/eloquent#global-scopes - Scoped route-model binding (
scopeBindings): https://laravel.com/docs/routing#implicit-model-binding-scoping - Validation
Rule::exists()->where(...): https://laravel.com/docs/validation#rule-exists - Filament multi-tenancy: https://filamentphp.com/docs/4.x/users/tenancy (v3: https://filamentphp.com/docs/3.x/panels/tenancy)
- stancl/tenancy: https://tenancyforlaravel.com Β· spatie/laravel-multitenancy: https://spatie.be/docs/laravel-multitenancy
- Pest architecture tests (
toUseTrait): https://pestphp.com/docs/arch-testing