How it works
A short tour of the decisions baked into Filament Tenancy: what lives in which database, how a request finds its tenant, and what happens between "Create organization" and a ready panel. You don't need this to install the plugin — read it when you want to know why things are wired the way they are.
Isolation model
Two kinds of database, with a fixed split between them:
| Database | Holds |
|---|---|
| Central (one) | users, sessions, auth, tenants, domains, the tenant_user pivot — everything about who and which workspace |
| Tenant (one per tenant) | Every table in database/migrations/tenant/ — the workspace's own data |
Tenant isolation happens at the connection level: while a request runs in tenant context, the default database connection is that tenant's database. Your models and resources need no tenant_id column and no global scope — a plain Project::all() returns that tenant's projects.
Identity stays central on purpose. Login happens on exactly one origin (the central domain); every host gets its own first-party session cookie; and identity reaches custom domains through a single-use, short-lived handoff code (Custom domains). That is what makes the tenant switcher, cross-subdomain login, and "logged out everywhere" revocation work — and it's the same model Clerk, Shopify, and Auth0 use now that third-party cookies are gone.
Consequently:
DatabaseSessionBootstrapperstays out oftenancy.bootstrappers. The installer publishesconfig/tenancy.phpwith it disabled; if it's ever re-enabled, the plugin strips it at runtime and logs a warning (also visible inphp artisan about) rather than letting tenant pages fail withno such table: sessions.usersandsessionsmigrations don't belong indatabase/migrations/tenant/.
Which identity model do you need?
"Per-tenant users" means two different things:
| You want… | Use |
|---|---|
A users table inside each tenant database — for foreign keys (posts.user_id), audit columns, tenant-side relations — while people still sign in once and switch between workspaces |
This plugin, with resource syncing. Your central User is the sync master; a tenant-side mirror is written into every tenant database the user belongs to and kept in sync. Auth and sessions stay central. This is the common case. |
| Every tenant as its own auth realm — separate credentials per tenant, login on each tenant's host, sessions in the tenant database, no shared account | Plain stancl/tenancy with Filament's multi-tenancy docs. That's the model stancl is designed around, and it does it very well. This plugin is built for shared identity, and the two don't mix. |
The tell is the login page: one sign-in that lands you in a workspace picker → this plugin; a different sign-in per tenant host → stancl directly.
Identification
How a request finds its tenant depends on the mode you chose in the installer. In both modes you add no stancl middleware to the panel — the plugin does the identification.
Subdomain mode
The plugin registers its own central-domain-aware identification middleware in the global stack. On each request it:
- skips the central domain (parsed from
APP_URL, orTENANCY_CENTRAL_DOMAINif set); - matches tenant hosts — subdomains and verified custom domains — against the
domainstable; - sets the session cookie domain for that host: a shared
.myapp.testcookie on subdomains, a host-only cookie on custom domains (manage_session_cookie => falseturns this off); - keeps auth pages on the central origin.
Filament then resolves the tenant, and the plugin's TenantSet bridge initializes stancl tenancy after authentication.
Warning
Adding InitializeTenancyByDomain or PreventAccessFromUnwantedDomains to the panel middleware — the wiring some stancl guides show — breaks the central domain (TenantCouldNotBeIdentifiedOnDomainException on login), because stancl's skip logic only applies in the global stack.
Path mode
Nothing to add either. Filament binds {tenant} from the URL and the TenantSet bridge does the rest. The installer sets the PathTenantResolver's tenant_model_column to 'slug' in the published config/tenancy.php so stancl resolves by the same column Filament binds on. Adding InitializeTenancyByPath would throw on the central /login route.
The provisioning pipeline
Creating a tenant — through the onboarding wizard, a seeder, or TenantOnboarder::create() — commits the tenant row, its {slug}.{central-domain} domain row, and the owner pivot in one transaction, then dispatches the pipeline to your queue:
CreateDatabase → MigrateDatabase → SeedDatabase (optional) → MarkTenantReady
- Until the last job finishes, the tenant's status is
provisioning.HasPackstubTenantshides it from the switcher, and theEnsureTenantIsReadymiddleware routes every visit to a polling status page. SeedDatabaseruns only whenpackstub-tenancy.seedernames a tenant-safe seeder class — never your centralDatabaseSeeder.- If a job fails, the status becomes
failedand the status page says so (with a support link if you setsupport_url).php artisan tenants:retry-provisioning {slug}re-queues the pipeline; an already-created database is reused. - Once
ready, the switcher lists it and the status page redirects into the panel.
Workers need the same database.connections entries as your web servers — provisioning resolves tenant database servers by connection name. See Production.
Where the data goes with a database pool
With a single database server, every tenant database is created on the central connection's server. With a database pool, each new tenant is assigned to one of your servers (by strategy — least tenants, weighted, round-robin) and the assignment is stored on the tenant row, so a tenant's data has one home for its whole life. Add a server, and new tenants start landing there.