orchestra/tenanti
Orchestra Tenanti brings multi-tenancy to Laravel: register tenants, switch database connections per tenant, and run tenant-aware migrations and Artisan commands. Includes tenant resolvers, configurable drivers, and a simple API for building SaaS apps with isolated data.
Pin before you write a line. composer require orchestra/tenanti and immediately lock the version in composer.json ("orchestra/tenanti": "6.3.*", not ^6.3), commit the lockfile. The package extends framework internals (Migrator, DatabaseManager), so version drift against your Laravel major is the most common first-hour failure. Check the constraint against your actual Laravel version before anything else.
Publish and read the config end to end. php artisan vendor:publish --provider="Orchestra\Tenanti\TenantiServiceProvider" gives you config/tenanti.php — a map of tenant drivers, a default, and the migration path convention. It is small and is effectively the whole public surface. Everything else lives in source, which is ~2,000 lines and worth reading once.
Define one driver, extending Orchestra\Tenanti\Tenant. You tell Tenanti how to reach a tenant; it does not guess. Implement the two methods it exists for:
namespace App\Tenancy;
use Orchestra\Tenanti\Tenant;
class UserDriver extends Tenant
{
public function getConnectionName(): string
{
return 'tenant_'.$this->getKey();
}
public function getConfig(): array
{
return array_merge(config('database.connections.mysql'), [
'database' => 'tenant_'.$this->getKey(),
]);
}
}
Register it against a key (e.g. 'user') in config/tenanti.php; that key becomes the first argument to every Artisan command.
Separate tenant migrations from control-plane migrations. Do not reuse database/migrations — those run against the central database and must never touch a tenant schema. Give each driver its own directory (database/migrations/{driver} or whatever the published config specifies). Two directories, two histories, always.
First use case, in ten minutes. Two tenants, one trivial migration:
php artisan tenanti:migrate user --tenants=1,2 --pretend
php artisan tenanti:migrate user --tenants=1,2
The commands mirror the migrate family you already know: tenanti:migrate, tenanti:rollback, tenanti:seed, tenanti:refresh, tenanti:reset. If --pretend prints SQL against two different database names, your wiring is correct — stop spiking and start building.
One driver per tenant type, not per tenant. A User driver, an Organisation driver. The driver name namespaces migration paths and Artisan arguments, so branching inside a single class to handle multiple tenant shapes keeps migration history tangled. Keep them apart.
Hook provisioning to tenant creation. The package ships an observer for exactly this:
User::observe(new Orchestra\Tenanti\Observer());
The moment a tenant row is created, its migration set is applied. Wrap that in your own onboarding action so you control transactionality and can emit a TenantProvisioned event once the schema is actually ready, not merely created.
Fan out in waves, not in one loop. --tenants=1,2,3 accepts a list; feed it chunks from a query and record results per tenant. A workflow that holds up:
--tenants=<canary> --pretend — eyeball the SQL.--tenants=<canary> — the real thing on a real tenant.Reconcile drift with SQL, not tooling. Each tenant database has its own migrations repository table, so "which schema version is this tenant on?" is a single query. Build that into a health check from day one — it is your only visibility into per-tenant state, and the package provides no reconciliation for you.
Canary schema changes through the product. Reserve a handful of design-partner tenant IDs as a canary cohort. Run a new column on them for a week before everyone gets it. "Promote to all tenants" becomes an explicit, scheduled action rather than an implicit every-deploy event.
Baseline existing tenants; do not replay history. When converting a single-database install, snapshot the current schema as "tenant v0", create tenant databases from that baseline, then insert the matching rows into each tenant's migrations table so Tenanti believes they are current. Replaying the historical migration chain across N tenants works until one migration is not idempotent.
Tenant-scoped console work, jobs, and schedules. Any command or job touching tenant tables must establish context itself. The pattern that survives: resolve the tenant, let Tenanti register the connection, then target it explicitly — DB::connection($tenant->getConnectionName()) — rather than trusting the default connection to be correct.
Wrap it behind your own service. Zero dependents and a thin maintenance base mean you should assume you may vendor or fork. A small TenantSchema class exposing migrate, rollback, seed, schemaVersion keeps every call site off Orchestra\Tenanti\* directly, so the swap is one class, not a codebase-wide search.
The default connection will betray you. Tenanti routes tenant-aware queries, but queue workers, scheduled commands, and long-lived objects holding a stale connection instance can write to the wrong database. That is a data-integrity incident. Defend with one chokepoint — middleware / job middleware that sets and tears down tenant context — plus an assertion at the top of every tenant job that the active connection name matches the expected tenant. Any code path touching a tenant table without that assertion is a bug.
Purge connections in long-running workers. Every resolved tenant opens a pool connection; a worker churning through tenants will exhaust MySQL's max_connections overnight. Call DB::purge($connectionName) when the job finishes, and cap worker concurrency accordingly.
Cross-tenant operations are not transactional. No distributed transaction spans tenant databases. A fan-out migration that fails halfway leaves some tenants migrated and some not. Design for re-run, not atomicity.
No locking out of the box. Two deploys — or a deploy plus a manual tenanti:migrate — can hit the same tenant concurrently. Ship a single deploy lock first; graduate to a per-tenant cache lock when fan-out time starts hurting.
--force and --no-interaction. Production needs --force, CI needs --no-interaction. Both are easy to forget until a deploy hangs on a prompt nobody can see.
Keep --pretend in your muscle memory. It is the cheapest way to confirm which connection a migration resolves to. If it prints the control-plane database name, getConfig() is not returning what you think.
Foreign keys cannot cross databases. Relationships between a tenant table and the central users/tenants tables must be enforced in application code. Accept it, and write the consistency/cleanup job a foreign key would otherwise have made unnecessary.
Assume the framework coupling will bite. Specialising Migrator and DatabaseManager means a Laravel major can silently change connection resolution or batch semantics. Budget half a day per major upgrade; run a CI harness (two ephemeral tenant databases, tenanti:migrate then tenanti:rollback on every build) before merging.
Read the source for anything undocumented. The docs are thin. Questions like "what exactly happens if a tenant migration fails midway" are answered only in code. That fifteen minutes will save you an incident.
How can I help you explore Laravel packages today?