sbine/simple-tenancy
Simple multi-tenancy for Laravel: add a trait to scope Eloquent models to the current tenant, with automatic query filtering and tenant-aware model creation. Lightweight and straightforward, it's ideal for apps that need clean tenant isolation without per-tenant databases.
composer require sbine/simple-tenancy. It is MIT-licensed and lightweight, so expect a small service provider rather than a full tenancy stack (no database-per-tenant orchestration, no tenant-aware queue/cache drivers out of the box).php artisan vendor:publish --provider="Sbine\SimpleTenancy\SimpleTenancyServiceProvider" (confirm the exact provider FQN in the README — packages of this size often expose a config tag like --tag=tenancy).config/tenancy.php (if published), and the package's middleware + trait. The middleware is where tenant resolution happens, and the trait is where query scoping happens. Those two files define the entire mental model of the package.web middleware stack or a named tenant group) — do not drop it in global until you've verified it is idempotent.tenant_id column to every model you want scoped, then apply the package's tenant trait to those models. The trait typically bootstraps a global scope that appends where tenant_id = <current> automatically.tenants table with id, slug/domain, and a posts table with tenant_id. Resolve the tenant by subdomain in middleware, apply the trait to Post, and hit /acme/posts. If the query returns only Acme's rows without you writing a where, the package is wired correctly.X-Tenant header, session value, or route model binding) in one middleware class. Everything downstream — global scopes, the tenant helper, relationship defaults — depends on that context being set before the request hits a controller.users if global, plans, countries) should stay untraited, otherwise you'll spend time fighting scopes that shouldn't be there.tenant_id on model creation via a creating hook. If yours doesn't, add it in your model's booted() or a BelongsToTenant::creating listener — otherwise every ->create() call becomes a manual tenant assignment and someone will forget.Model::withoutGlobalScope(...) (or the package's equivalent opt-out) explicitly and locally. Do not disable the scope globally just to make one report work.Route::middleware(['web', 'tenant'])->prefix('{tenant}')->group(function () {
// tenant-scoped routes
});
and let the middleware resolve {tenant} into the package's current-tenant store. This keeps controllers free of resolution logic.$tenant->posts and Post::find() both honor the current tenant. That is the biggest day-to-day win — you stop passing tenant_id around in controllers.setUp(). For unit tests of scoped models, wrap in the same helper so the global scope has something to bind to.tenant_id values. That mirrors production behavior and catches scope bugs.dispatch() that later touches a scoped model will run with no tenant bound — either serialize tenant_id into the job and re-bind in handle(), or the query will fail/leak. Add a TenantAware job trait early.php artisan migrate, seeders, and any custom commands run without a resolved tenant. Global scopes on artisan-invoked queries will silently return nothing (or throw). Provide a --tenant= flag or an explicit resolver call in command signatures.withoutGlobalScopes() is a footgun. Calling it to "fix" a suspiciously empty result usually hides a mis-resolved tenant rather than the real bug. Log the resolved tenant id in middleware first.config/tenancy.php, run php artisan config:clear. On this package size, the config is often read once at boot — a stale cached config gives confusing "my change did nothing" symptoms.tenant_id (and its index, ideally a composite (tenant_id, created_at) for list screens) before you attach the trait and run queries, or the scope will reference a column that doesn't exist yet.unique:users,email,NULL,id,tenant_id,{$tenant->id}). Global unique constraints are a classic tenancy bug: tenant B can never register an email tenant A already used.CurrentTenant) so you can swap subdomain → header → token without touching the scope.cache()->remember("tenant:{$id}:stats", ...)) or you'll serve Acme's dashboard numbers to Globex.composer.json, read the scope and middleware code yourself (they're small), and have a plan to inline those ~100 lines if you outgrow it. For anything beyond simple single-DB row scoping, evaluate stancl/tenancy instead.How can I help you explore Laravel packages today?