Packstub.

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:

  • DatabaseSessionBootstrapper stays out of tenancy.bootstrappers. The installer publishes config/tenancy.php with it disabled; if it's ever re-enabled, the plugin strips it at runtime and logs a warning (also visible in php artisan about) rather than letting tenant pages fail with no such table: sessions.
  • users and sessions migrations don't belong in database/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:

  1. skips the central domain (parsed from APP_URL, or TENANCY_CENTRAL_DOMAIN if set);
  2. matches tenant hosts — subdomains and verified custom domains — against the domains table;
  3. sets the session cookie domain for that host: a shared .myapp.test cookie on subdomains, a host-only cookie on custom domains (manage_session_cookie => false turns this off);
  4. 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. HasPackstubTenants hides it from the switcher, and the EnsureTenantIsReady middleware routes every visit to a polling status page.
  • SeedDatabase runs only when packstub-tenancy.seeder names a tenant-safe seeder class — never your central DatabaseSeeder.
  • If a job fails, the status becomes failed and the status page says so (with a support link if you set support_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.