cortex/tenants
Cortex Tenants brings multi-tenancy to Laravel applications, helping resolve tenants, scope Eloquent queries, and isolate data per tenant. It provides a lightweight foundation for building SaaS or multi-tenant systems within the Cortex ecosystem.
Cortex Tenants is the tenant model + resolution layer of the Cortex stack, not a standalone tenancy engine. Before you write a line of code, know that it is archived — there will be no fixes for new Laravel/PHP releases, so treat version pinning as part of the install, not an afterthought.
composer require cortex/tenants --dry-run and composer why-not laravel/framework to see what blocks you. If you are on Laravel 10+, budget time for a fork rather than expecting it to work.cortex/support, rinvex/attributes, and cortex/tenantable (the trait that actually makes a model tenant-aware). Require them by exact version in composer.json so a transitive bump can't silently change semantics.php artisan vendor:publish --provider="Cortex\Tenants\Providers\TenantsServiceProvider"
php artisan migrate
Check the provider in src/Providers/TenantsServiceProvider.php for the real publish tags (config, migrations, views) — they differ slightly between Cortex packages.
Tenant model (src/Models/Tenant.php — route key, fillable, relationships), the published migration (tenants table plus the polymorphic pivot), the config file (table names, route prefix, middleware group), then cortex/tenantable's trait, which is where attaching/scoping actually happens.$acme = Tenant::create(['name' => 'Acme', 'slug' => 'acme']);
$globex = Tenant::create(['name' => 'Globex', 'slug' => 'globex']);
$post = Post::create(['title' => 'Only Acme sees this']);
$post->tenants()->attach($acme);
// Isolation check
Post::whereHas('tenants', fn ($q) => $q->whereKey($acme->id))->count(); // 1
Post::whereHas('tenants', fn ($q) => $q->whereKey($globex->id))->count(); // 0
If that passes, the rest of the package is mostly wiring around it.
Resolution belongs in middleware, once. The package gives you a Tenant model and route binding; it does not decide which tenant a request is for. Pick one strategy and centralise it:
ResolveTenant middleware, abort 404 on miss, and bind into the container so downstream code reads one source of truth.public function handle($request, Closure $next)
{
$tenant = Tenant::where('slug', $request->route('tenant'))->firstOrFail();
app()->instance('tenant.current', $tenant); // or use the package's resolver
return $next($request);
}
Attach, don't scope globally. The Cortex model is many-to-many via a polymorphic pivot (tenantables-style). There is no automatic where tenant_id = ? global scope, and that is deliberate: it lets you keep reference/lookup tables central while only tenant-owned models are attached. Build one query scope or repository method that expresses "visible to current tenant" and use it everywhere, rather than sprinkling whereHas('tenants') through controllers.
Partition your schema explicitly. Write down which tables are central (users, countries, currencies, plans) and which are tenant-owned. Attaching everything "just in case" makes queries slower and makes the pivot a second source of truth you have to keep in sync.
Authorisation is a separate scope. Cortex Auth (Bouncer-based) scopes roles/abilities by tenant. The common wiring is to set the authorisation scope from the resolved tenant in the same middleware, so Gate/can() checks don't leak across tenants. Do this after tenant resolution and before the controller.
Onboarding as one transaction. Creating a tenant and seeding its defaults is where you will get partial state. Wrap Tenant::create() + default role assignment + seed data in a transaction, and fire any job after commit (DB::afterCommit or ->afterCommit() on the job).
Admin CRUD via Cortex Fort if you're using it: register the tenant resource there rather than hand-rolling controllers, and make sure route model binding uses the slug so URLs are readable and stable.
Testing pattern. Give every feature test at least two tenants. Reset the resolved-tenant singleton in setUp/tearDown so leaked state can't make an isolation test pass vacuously.
repositories with a path/VCS entry) if you need a patch, and record why in the fork README. Keep composer.lock committed; a composer update on an unmaintained graph is how these installs break.whereHas is your N+1 trap. Listing tenant-owned records without eager loading produces a subquery per row. Either eager-load tenants or maintain an indexed tenant_id column alongside the pivot if read volume justifies denormalising.Tenant does not cascade to tenant-owned models in most setups. Delete or detach explicitly in an observer/deleting hook, and decide up front whether tenant deletion is soft or hard.handle(). This is the most common source of cross-tenant writes.Relation::morphMap(), the pivot's tenantable_type values must match. Register it in a service provider before anything writes pivot rows, or you get orphaned rows that silently disappear from queries.cortex/tenantable's trait and your model's $fillable/morph setup — the tenants package is mostly the model, migrations, and route/CRUD surface. Dump ->toSql() and ->getBindings(), and inspect the pivot table directly rather than assuming the relation definition is wrong.php artisan config:clear in local/staging; config:cache will happily serve stale table names.Tenant model in a service provider; publish and edit migrations if column names collide with your schema; publish views/routes only as a last resort, since you're now maintaining archived code.How can I help you explore Laravel packages today?