Multi-tenancy
The kits run stancl/tenancy (^3.10) in multi-database mode. Each tenant (workspace) gets its own database, and the request's domain tells the app which tenant it's dealing with. Because the data is split at the database level, your tenant code doesn't need where tenant_id = ... filters.
vue.test → central app (central DB: saas_laravel_vue)
acme.vue.test → tenant 1 (DB: tenant1)
globex.vue.test → tenant 2 (DB: tenant2)Identification
The host of each request decides which context it runs in:
host == APP_DOMAIN → central app
host found in domains table → tenancy initialized for that tenant
unknown host → 404APP_DOMAINis the only central domain (central_domainsinconfig/tenancy.php).InitializeTenancyIfTenantDomainruns first on every request and passes any non-central host to stancl'sInitializeTenancyByDomain.- Tenant domains are always subdomains of
APP_DOMAIN. If you enteracmeas the subdomain, it's stored asacme.vue.test. See Domains.
What is tenant-aware
As soon as a tenant is identified, the bootstrappers in config/tenancy.php point these Laravel services at it:
| Bootstrapper | Effect inside a tenant |
|---|---|
DatabaseTenancyBootstrapper | Default connection becomes the tenant database |
CacheTenancyBootstrapper | Cache calls are tagged per tenant |
FilesystemTenancyBootstrapper | local and public disks and storage_path() are suffixed per tenant |
QueueTenancyBootstrapper | Jobs remember the tenant and re-initialize it when processed |
The kit also switches the auth guard, Fortify features, app name and locale. Architecture explains how.
Managing tenants
The tenant screens live in Modules/Tenant and only exist in the central app (central.only middleware):
| Route | Page | Permission |
|---|---|---|
GET /tenants | List with search, status filter and stats (total, active, trial, pending, suspended) | View Tenants |
GET /tenants/create | Create form | Create Tenant |
GET /tenants/{tenant} | Detail: profile, domains, admin password, invitation | View Tenants |
PUT /tenants/{tenant} | Edit | Edit Tenant |
DELETE /tenants/{tenant} | Delete (drops the tenant database) | Delete Tenant |
GET /tenants/domains | All domains | View Tenants |
Tenant data
Tenants live in the central tenants table (App\Models\Tenant). A few attributes are real columns, and stancl's virtual columns put the rest in the data JSON column. You don't have to care which is which: $tenant->industry reads and writes the same way.
| Stored as | Attributes |
|---|---|
| Columns | id, first_name, last_name, email, phone, company, team_size |
data JSON | industry, registration_number, tax_number, workspace_status, work_week, status_message |
The select options come from enums in Modules/Tenant/Enums:
| Enum | Values |
|---|---|
TeamSizeEnum | 1-10, 11-50, 51-200, 201-500, 500+ |
IndustryEnum | technology, fintech, healthcare, ecommerce, agency, education, other |
WorkWeekEnum | mon_fri, mon_sat, sun_thu |
WorkspaceStatusEnum | See Workspace status |
Creating a tenant
When you add a tenant from Tenants → Add Tenant, a single request sets up everything the workspace needs:
TenantController::store() → validates TenantRegisterData
TenantService::createTenant()
1. creates the Tenant
2. creates its primary domain <subdomain>.APP_DOMAIN
3. sends the invitation, if requested
TenantCreated event → provisioning pipeline (synchronous)| Pipeline step | Result |
|---|---|
CreateDatabase | CREATE DATABASE tenant<id> |
MigrateDatabase | Runs database/migrations/tenant |
SeedDatabase | Runs Database\Seeders\tenant\TenantDatabaseSeeder (roles, tenant permissions, tenant menus, default users) |
CreateTenantUserJob | Creates the tenant admin (tenant's email) with the super-admin role and every tenant permission |
Deleting a tenant fires TenantDeleted, which runs DeleteDatabase.
View implementation example
// App\Providers\TenancyServiceProvider::events()
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
Jobs\SeedDatabase::class,
CreateTenantUserJob::class,
])->send(fn (Events\TenantCreated $event) => $event->tenant)
->shouldBeQueued(false),
],Queuing the pipeline
The pipeline runs inside the request, so it gets slow once you have a lot of migrations. You can change shouldBeQueued(false) to true, but then the tenant isn't usable until the worker is done. There's a catch: CreateTenantUserJob reads the admin password and the invitation flag from the current request, and a queue worker doesn't have that request. Change the job before you queue it.
Workspace status
Each tenant has a status, defined in Modules\Tenant\Enums\WorkspaceStatusEnum:
| Status | Value | Behaviour |
|---|---|---|
| Active | active | Normal access |
| Trial | trial | Normal access (label only) |
| Pending Invitation | pending | Set automatically when an invitation is sent; becomes active when the admin accepts |
| Suspended | suspended | Every tenant page renders auth/Suspended (HTTP 403) with the optional status message; only logout works |
For what a suspended tenant can and can't do, see Maintenance & suspension.
Invitations
With an invitation, the tenant admin picks their own password, so you don't have to set one in the create form.
Create tenant with "send invitation" on
→ admin created without a password, workspace = pending
→ queued email with a signed link on the primary domain (/invitation/accept, valid 7 days)
→ admin sets a password (auth/AcceptInvitation)
→ admin is verified and signed in, workspace = active- If a tenant is still pending, you can send the invitation again from the tenant page (
POST /tenants/{tenant}/invitation, throttled to 3 per minute). - The email goes through the queue, so you need a queue worker running.
Related files: TenantService::sendInvitation(), Modules/Tenant/Notifications/TenantInvitationNotification.php, Modules/Tenant/Http/Controllers/TenantInvitationController.php, Modules/Tenant/routes/tenant.php.
Admin password reset
A central admin with Edit Tenant can reset the tenant admin's password from the tenant's domains:
| Option | Route | What happens |
|---|---|---|
| Email a reset link | POST /tenants/domains/{domain}/password-reset | Creates a tenant_users token and sends TenantPasswordResetNotification with a link on that domain |
| Set a password manually | PUT /tenants/domains/{domain}/password | Updates the password straight away |
Working in tenant context from code
In a tenant request, tenancy is already initialized for you. Anywhere else (commands, jobs, central pages), wrap the code in run() to execute it for a specific tenant:
$tenant->run(function () {
User::query()->count(); // tenant database
});
tenancy()->initialized; // true inside a tenant request
tenant(); // current Tenant or nullTenant migrations and seeders
| What | Location |
|---|---|
| Tenant migrations | database/migrations/tenant |
| Tenant seeder | database/seeders/tenant/TenantDatabaseSeeder.php |
php artisan tenants:migrate # migrate all tenants
php artisan tenants:migrate --tenants=1 # one tenant
php artisan tenants:seed # run TenantDatabaseSeeder for all tenantsYou also get tenants:list and tenants:run <command>. The Database page has the details.