Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Tenancy Laravel Package

stancl/tenancy

Automatic multi-tenancy for Laravel with no code changes needed. Database-per-tenant, hostname and subdomain identification, and no model traits or class swapping. Docs at tenancyforlaravel.com.

View on GitHub
Deep Wiki
Context7

Getting Started

Install & scaffold

composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate   # central migrations: tenants, domains

tenancy:install publishes config/tenancy.php, routes/tenant.php, central/tenant migration folders, App\Models\Tenant + App\Models\Domain, and App\Providers\TenancyServiceProvider (the event → listener map you'll grow during provisioning).

Read these three files first, in this order

  1. config/tenancy.php — tenant_model, domain_model, central_domains, bootstrappers, migration_parameters, database managers, features.
  2. routes/tenant.php — the middleware stack that decides how a tenant is identified.
  3. app/Models/Tenant.php — the only model you'll touch constantly.

First use case: one database per customer

use App\Models\Tenant;

$tenant = Tenant::create(['id' => 'acme']);      // id is the tenant key
$tenant->domains()->create(['domain' => 'acme.localhost']);

// shell
php artisan tenants:migrate   # runs database/migrations/tenant on every tenant
php artisan tenants:seed

Then hit http://acme.localhost:8000 with the default middleware stack:

// routes/tenant.php
Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
    PreventAccessFromCentralDomains::class,
])->group(function () {
    Route::get('/', fn () => 'Tenant: ' . tenant('id'));
});

If that returns Tenant: acme, you're done with setup — everything below is where the real work is.

Implementation Patterns

The connection contract. Add a tenant connection to config/database.php whose credentials are placeholders; the DatabaseTenancyBootstrapper rewrites it from tenancy.database.template_tenant_connection + the tenant's data.tenancy_db_name. Tenant models just say protected $connection = 'tenant'; — no traits, no per-model connection gymnastics, which is the package's core selling point.

Three ways to move between contexts — pick deliberately, they are not interchangeable:

tenancy()->initialize($tenant);   // imperative switch (commands, seeders, tests)
// ... tenant-scoped work ...
tenancy()->end();

$tenant->run(function () {        // scoped switch, auto-reverts
    User::count();
});

tenant();                         // null in central context — guard your code
tenant('id');

tenancy()->central(fn () => User::count()); // reach back from tenant context

Rule of thumb: HTTP requests get context from middleware, everything else (artisan, jobs, tests, scheduled closures) uses initialize() / run().

Identification strategies — all in Stancl\Tenancy\Middleware\*, and choosing wrong is the #1 source of "why is my tenant data on the central domain":

Middleware Use when
InitializeTenancyByDomain one custom domain per tenant
InitializeTenancyBySubdomain acme.app.com, with reserved subdomain list in config
InitializeTenancyByDomainOrSubdomain mixed (custom domain or fallback subdomain)
InitializeTenancyByPath /acme/... routes; pass the parameter name
InitializeTenancyByRequestData API keys: reads header/query/cookie

Always pair with PreventAccessFromCentralDomains on tenant routes, and use ScopeSessions when tenants share a cookie domain.

Provisioning as an event chain. Tenant::create() fires TenantCreated; the database manager creates the DB (MySQL/PostgreSQL/SQLite/MariaDB managers, selected in tenancy.database). Wire the rest in TenancyServiceProvider:

Events\TenantCreated::class => [
    JobPipeline::make([
        Jobs\CreateDatabase::class,
        Jobs\MigrateDatabase::class,
        Jobs\SeedDatabase::class,
        // your own:
        CreateDefaultAdmin::class,
    ])->send(function (Events\TenantCreated $event) {
        return $event->tenant;
    })->shouldBeQueued(false),
],

Keep the pipeline synchronous for the first version; move to queued jobs once provisioning gets slow, and make every step idempotent (re-provisioning a failed tenant must work).

Queues. With QueueTenancyBootstrapper enabled, jobs dispatched inside tenant context carry the tenant key and the worker re-initializes the tenant before handle(). Since v3.5 you can inject full Eloquent models, not just IDs. Dispatch from tenant context, not from central code looping tenants, unless you explicitly $tenant->run(fn () => MyJob::dispatch()).

Scheduler. Central schedule is central. Loop tenants explicitly:

$schedule->call(function () {
    Tenant::cursor()->each(fn ($tenant) => $tenant->run(
        fn () => Artisan::call('reports:daily')
    ));
})->daily();

Or tenants:artisan "reports:daily" inside a scheduled command — it handles the iteration and context for you.

Per-tenant config. Enable the TenantConfig feature in tenancy.features, store overrides in tenant data, and nested keys (e.g. mail.from.address) are merged on initialize():

$tenant->set('config', ['app.locale' => 'de', 'mail.from.address' => '[email protected]']);

This is the cleanest way to handle locale, mail senders, and feature flags without a settings table.

Testing. Create a tenant per test (not per suite — tenant state leaks), and migrate only that tenant:

$tenant = Tenant::create(['id' => 'test']);
$this->artisan('tenants:migrate', ['--tenants' => [$tenant->getTenantKey()]]);
$this->app->instance('tenant', $tenant); // or tenancy()->initialize($tenant)

Use RefreshDatabase for central tables only; tenant migrations are a separate concern and can't be handled by Laravel's trait.

Gotchas and Tips

  • CacheTenancyBootstrapper requires a taggable store. file and database cache drivers will throw when tenancy initializes. Use Redis or Memcached, or drop the bootstrapper and namespace keys yourself.
  • tenants:* vs tenancy:*. The trailing s is the whole thing: tenants:migrate / tenants:artisan loop all tenants (--tenants=id1,id2 to target), tenancy:run runs against the current tenant. Mixing these up is a classic "I migrated only one tenant" incident.
  • Deleting a Tenant row does not drop the database. Hook TenantDeleted or call $tenant->database()->manager()->deleteDatabase($tenant). Orphan databases accumulate and are billed by your provider.
  • The data JSON column hides attributes. tenancy_db_name, config, etc. live in data unless you add real columns and declare them via $customColumns. Filtering requires Tenant::where('data->tenancy_db_name', ...) — JSON path syntax, so MySQL vs PostgreSQL vs SQLite all behave slightly differently.
  • Tenant keys become DB names and storage paths. Tenant::create(['id' => 'acme']) → database tenantacme (configurable prefix) and storage/tenantacme. Enforce a slug-ish validation rule, and remember PostgreSQL's 63-byte identifier limit — long UUIDs plus a prefix will truncate.
  • Filesystem bootstrapper rewrites storage_path() and (if asset_helper_tenancy is on) asset(). Per-tenant public disks need their own storage:link-equivalent; use tenancy()->asset() / the ViteBundler feature for Vite builds, and remember php artisan optimize caches are per-tenant too.
  • Session leakage across subdomains. Laravel's default cookie domain is per-host, but subdomains plus SESSION_DOMAIN=.localhost means one tenant's session is readable by another. Add ScopeSessions and decide consciously between shared SSO and isolated sessions.
  • route:cache breaks closure routes. routes/tenant.php full of closures won't cache. Convert to invokable controllers before production deploys with route caching enabled.
  • Long-lived processes: revert your own state. The bootstrappers revert connection, cache, storage, and queue prefixes; your custom bootstrapper must implement revert() symmetrically or queue workers and Octane will bleed tenant config into the next request/job. Related: don't cache tenanted Eloquent models or services in singletons/static properties.
  • Debugging checklist when tenant context looks wrong: tenancy()->initialized, tenancy()->tenant?->getTenantKey(), config('database.connections.tenant.database'), and whether tenancy.central_domains contains the exact host you're hitting (otherwise PreventAccessFromCentralDomains 404s you on localhost vs 127.0.0.1).
  • Local subdomains: *.localhost resolves on modern macOS/Linux/Chrome; for artisan serve use --host=acme.localhost or add hosts entries. 127.0.0.1 is a different tenant identity than localhost unless both are in central_domains.
  • Extension points worth knowing: implement TenancyBootstrapper for anything global-scoped (search clients, feature flags); implement TenantDatabaseManager for exotic DBs; swap tenant_model/domain_model in config for custom behavior; tenancy.features is a clean registry for opt-in behaviors (UniversalRoutes, UserImpersonation, Telescope, CrossDomainRedirect, ViteBundler) instead of fork-patching.
  • Version pairing: pin the minor. 3.9 dropped Laravel 9 and added Laravel 12; 3.10 added Laravel 13 and the MariaDB manager; v4 docs are live at v4.tenancyforlaravel.com — read the docs for your installed major, not the README's link, before copying snippets.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
roundly-consulting/kubernetes-api-for-laravel
roundly-consulting/plausible-for-laravel
gvp/gvp-laravel-template
metasyncsite/laravel-metasync-client
roundly-consulting/onboarding-for-laravel
andydefer/laravel-notification
adachsoft/cli-process-tester
jhaskell13/laraflow
sebastienheyd/boilerplate-media-manager
ccast/tagixo-primix
pretendtrue/laravel-builder
sawmainek/laravel-scout-tntsearch-driver
tomshaw/laravel-calendar-table
dibakar/laravel-ownership
steadrun/laravel-monitor
labyrinthe/connect-laravel
elmogy/layan
drewdan/sentinel-client
dskripchenko/laravel-llm
fatryst/nuonuo